diff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py index 5428f10..69901ec 100644 --- a/src/snek/mapper/__init__.py +++ b/src/snek/mapper/__init__.py @@ -10,10 +10,12 @@ from snek.mapper.user import UserMapper from snek.mapper.user_property import UserPropertyMapper from snek.mapper.repository import RepositoryMapper from snek.mapper.channel_attachment import ChannelAttachmentMapper +from snek.mapper.container import ContainerMapper from snek.system.object import Object @functools.cache + def get_mappers(app=None): return Object( **{ @@ -27,6 +29,7 @@ def get_mappers(app=None): "user_property": UserPropertyMapper(app=app), "repository": RepositoryMapper(app=app), "channel_attachment": ChannelAttachmentMapper(app=app), + "container": ContainerMapper(app=app), } ) diff --git a/src/snek/mapper/container.py b/src/snek/mapper/container.py new file mode 100644 index 0000000..f88f9f0 --- /dev/null +++ b/src/snek/mapper/container.py @@ -0,0 +1,6 @@ +from snek.model.container import Container +from snek.system.mapper import BaseMapper + +class ContainerMapper(BaseMapper): + model_class = Container + table_name = "container" \ No newline at end of file diff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py index 17832c6..dec05e8 100644 --- a/src/snek/model/__init__.py +++ b/src/snek/model/__init__.py @@ -12,6 +12,7 @@ from snek.model.user import UserModel from snek.model.user_property import UserPropertyModel from snek.model.repository import RepositoryModel from snek.model.channel_attachment import ChannelAttachmentModel +from snek.model.container import Container from snek.system.object import Object @@ -29,6 +30,7 @@ def get_models(): "user_property": UserPropertyModel, "repository": RepositoryModel, "channel_attachment": ChannelAttachmentModel, + "container": Container, } ) diff --git a/src/snek/model/container.py b/src/snek/model/container.py new file mode 100644 index 0000000..3947a8d --- /dev/null +++ b/src/snek/model/container.py @@ -0,0 +1,10 @@ +from snek.system.model import BaseModel, ModelField + +class Container(BaseModel): + id = ModelField(name="id", required=True, kind=str) + name = ModelField(name="name", required=True, kind=str) + status = ModelField(name="status", required=True, kind=str) + resources = ModelField(name="resources", required=False, kind=str) + user_uid = ModelField(name="user_uid", required=False, kind=str) + path = ModelField(name="path", required=False, kind=str) + readonly = ModelField(name="readonly", required=False, kind=bool, default=False) diff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py index dae9e09..5669bdb 100644 --- a/src/snek/service/__init__.py +++ b/src/snek/service/__init__.py @@ -13,6 +13,7 @@ from snek.service.user_property import UserPropertyService from snek.service.util import UtilService from snek.service.repository import RepositoryService from snek.service.channel_attachment import ChannelAttachmentService +from snek.service.container import ContainerService from snek.system.object import Object from snek.service.db import DBService @@ -34,6 +35,7 @@ def get_services(app): "repository": RepositoryService(app=app), "db": DBService(app=app), "channel_attachment": ChannelAttachmentService(app=app), + "container": ContainerService(app=app), } ) diff --git a/src/snek/service/container.py b/src/snek/service/container.py new file mode 100644 index 0000000..4025e9e --- /dev/null +++ b/src/snek/service/container.py @@ -0,0 +1,29 @@ +from snek.system.service import BaseService + +class ContainerService(BaseService): + mapper_name = "container" + + async def create(self, id, name, status, resources=None, user_uid=None, path=None, readonly=False): + model = await self.new() + model["id"] = id + model["name"] = name + model["status"] = status + if resources: + model["resources"] = resources + if user_uid: + model["user_uid"] = user_uid + if path: + model["path"] = path + model["readonly"] = readonly + if await super().save(model): + return model + raise Exception(f"Failed to create container: {model.errors}") + + async def get(self, id): + return await self.mapper.get(id) + + async def update(self, model): + return await self.mapper.update(model) + + async def delete(self, id): + return await self.mapper.delete(id) diff --git a/src/snek/templates/settings/containers/create.html b/src/snek/templates/settings/containers/create.html new file mode 100644 index 0000000..9b60f3e --- /dev/null +++ b/src/snek/templates/settings/containers/create.html @@ -0,0 +1,39 @@ +{% extends 'settings/index.html' %} + +{% block header_text %}<h1><i class="fa-solid fa-plus"></i> Create Container</h1>{% endblock %} + +{% block main %} +{% include 'settings/containers/form.html' %} + <div class="container"> + <form action="/settings/containers/create.html" method="post"> + <div> + <label for="name"><i class="fa-solid fa-box"></i> Name</label> + <input type="text" id="name" name="name" required placeholder="Container name"> + </div> + <div> + <label for="status"><i class="fa-solid fa-info-circle"></i> Status</label> + <input type="text" id="status" name="status" required placeholder="Container status"> + </div> + <div> + <label for="resources"><i class="fa-solid fa-memory"></i> Resources</label> + <input type="text" id="resources" name="resources" placeholder="Resource details"> + </div> + <div> + <label for="user_uid"><i class="fa-solid fa-user"></i> User UID</label> + <input type="text" id="user_uid" name="user_uid" placeholder="User UID"> + </div> + <div> + <label for="path"><i class="fa-solid fa-folder"></i> Path</label> + <input type="text" id="path" name="path" placeholder="Container path"> + </div> + <div> + <label> + <input type="checkbox" name="readonly" value="1"> + <i class="fa-solid fa-lock"></i> Readonly + </label> + </div> + <button type="submit"><i class="fa-solid fa-plus"></i> Create</button> + <button onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button> + </form> + </div> +{% endblock %} diff --git a/src/snek/templates/settings/containers/delete.html b/src/snek/templates/settings/containers/delete.html new file mode 100644 index 0000000..a0fc249 --- /dev/null +++ b/src/snek/templates/settings/containers/delete.html @@ -0,0 +1,17 @@ +{% extends 'settings/index.html' %} + +{% block header_text %}<h1><i class="fa-solid fa-trash-can"></i> Delete Container</h1>{% endblock %} + +{% block main %} +<div class="container"> + <p>Are you sure you want to <strong>delete</strong> the following container?</p> + <div class="container-name"><i class="fa-solid fa-box"></i> {{ container.name }}</div> + <form method="post" style="margin-top:1.5rem;"> + <input type="hidden" name="id" value="{{ container.id }}"> + <div class="actions"> + <button type="submit"><i class="fa-solid fa-trash"></i> Yes, delete</button> + <button type="button" onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i> Cancel</button> + </div> + </form> + </div> +{% endblock %} diff --git a/src/snek/templates/settings/containers/form.html b/src/snek/templates/settings/containers/form.html new file mode 100644 index 0000000..8785cc6 --- /dev/null +++ b/src/snek/templates/settings/containers/form.html @@ -0,0 +1,28 @@ +<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> + <style> + form { + padding: 2rem; + border-radius: 10px; + div { + padding: 10px; + padding-bottom: 15px + } + } + label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;} + button { + background: #0d6efd; color: #fff; + border: none; border-radius: 5px; padding: 0.6rem 1rem; + cursor: pointer; + font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem; + } + .cancel { + background: #6c757d; + } + @media (max-width: 600px) { + .container { max-width: 98vw; } + form { padding: 1rem; } + } + + </style> + + diff --git a/src/snek/templates/settings/containers/index.html b/src/snek/templates/settings/containers/index.html new file mode 100644 index 0000000..a6855dd --- /dev/null +++ b/src/snek/templates/settings/containers/index.html @@ -0,0 +1,96 @@ +{% extends 'settings/index.html' %} + +{% block header_text %}<h1><i class="fa-solid fa-database"></i> Containers</h1>{% endblock %} + +{% block main %} +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Containers - List</title> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> + <style> + .actions { + display: flex; + gap: 0.5rem; + justify-content: center; + flex-wrap: wrap; + } + .container-list { + display: flex; + flex-direction: column; + gap: 1rem; + } + .container-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border-radius: 8px; + flex-wrap: wrap; + } + .container-info { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; + min-width: 220px; + } + .container-name { + font-size: 1.1rem; + font-weight: 600; + } + @media (max-width: 600px) { + .container-row { flex-direction: column; align-items: stretch; } + .actions { justify-content: flex-start; } + } + .topbar { + display: flex; + margin-bottom: 1rem; + } + button, a.button { + background: #198754; color: #fff; border: none; border-radius: 5px; + padding: 0.4rem 0.8rem; text-decoration: none; cursor: pointer; + transition: background 0.2s; + font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem; + } + .button.delete { background: #dc3545; } + .button.edit { background: #0d6efd; } + .button.clone { background: #6c757d; } + .button.browse { background: #ffc107; color: #212529; } + .button.create { background: #20c997; margin-left: 0.5rem; } + </style> +</head> +<body> + <div class="container"> + <div class="topbar"> + <a class="button create" href="/settings/containers/create.html"> + <i class="fa-solid fa-plus"></i> New Container + </a> + </div> + <section class="container-list"> + {% for container in containers %} + <div class="container-row"> + <div class="container-info"> + <span class="container-name"><i class="fa-solid fa-box"></i> {{ container.name }}</span> + <span title="Status"><i class="fa-solid fa-info-circle"></i> {{ container.status }}</span> + <span title="Readonly"> + <i class="fa-solid {% if container.readonly %}fa-lock{% else %}fa-lock-open{% endif %}"></i> + {% if container.readonly %}Readonly{% else %}Writable{% endif %} + </span> + </div> + <div class="actions"> + <a class="button edit" href="/settings/containers/container/{{ container.id }}/update.html"> + <i class="fa-solid fa-pen"></i> Edit + </a> + <a class="button delete" href="/settings/containers/container/{{ container.id }}/delete.html"> + <i class="fa-solid fa-trash"></i> Delete + </a> + </div> + </div> + {% endfor %} + </section> + </div> +</body> +</html> +{% endblock %} diff --git a/src/snek/templates/settings/containers/update.html b/src/snek/templates/settings/containers/update.html new file mode 100644 index 0000000..1c258cd --- /dev/null +++ b/src/snek/templates/settings/containers/update.html @@ -0,0 +1,40 @@ +{% extends "settings/index.html" %} + +{% block header_text %}<h1><i class="fa-solid fa-pen"></i> Update Container</h1>{% endblock %} + +{% block main %} +{% include "settings/containers/form.html" %} + <div class="container"> + <form method="post"> + <input type="hidden" name="id" value="{{ container.id }}"> + <div> + <label for="name"><i class="fa-solid fa-box"></i> Name</label> + <input type="text" id="name" name="name" value="{{ container.name }}" required> + </div> + <div> + <label for="status"><i class="fa-solid fa-info-circle"></i> Status</label> + <input type="text" id="status" name="status" value="{{ container.status }}" required> + </div> + <div> + <label for="resources"><i class="fa-solid fa-memory"></i> Resources</label> + <input type="text" id="resources" name="resources" value="{{ container.resources }}"> + </div> + <div> + <label for="user_uid"><i class="fa-solid fa-user"></i> User UID</label> + <input type="text" id="user_uid" name="user_uid" value="{{ container.user_uid }}"> + </div> + <div> + <label for="path"><i class="fa-solid fa-folder"></i> Path</label> + <input type="text" id="path" name="path" value="{{ container.path }}"> + </div> + <div> + <label> + <input type="checkbox" name="readonly" value="1" {% if container.readonly %}checked{% endif %}> + <i class="fa-solid fa-lock"></i> Readonly + </label> + </div> + <button type="submit"><i class="fa-solid fa-pen"></i> Update</button> + <button onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button> + </form> + </div> +{% endblock %} diff --git a/src/snek/view/settings/containers.py b/src/snek/view/settings/containers.py new file mode 100644 index 0000000..dae0237 --- /dev/null +++ b/src/snek/view/settings/containers.py @@ -0,0 +1,91 @@ +import asyncio +from aiohttp import web + +from snek.system.view import BaseFormView +import pathlib + +class ContainersIndexView(BaseFormView): + + login_required = True + + async def get(self): + + user_uid = self.session.get("uid") + + containers = [] + async for container in self.services.container.find(user_uid=user_uid): + containers.append(container.record) + + user = await self.services.user.get(uid=self.session.get("uid")) + + return await self.render_template("settings/containers/index.html", {"containers": containers, "user": user}) + +class ContainersCreateView(BaseFormView): + + login_required = True + + async def get(self): + + return await self.render_template("settings/containers/create.html") + + async def post(self): + data = await self.request.post() + container = await self.services.container.create( + user_uid=self.session.get("uid"), + name=data['name'], + status=data['status'], + resources=data.get('resources', ''), + path=data.get('path', ''), + readonly=bool(data.get('readonly', False)) + ) + return web.HTTPFound("/settings/containers/index.html") + +class ContainersUpdateView(BaseFormView): + + login_required = True + + async def get(self): + + container = await self.services.container.get( + user_uid=self.session.get("uid"), uid=self.request.match_info["uid"] + ) + if not container: + return web.HTTPNotFound() + return await self.render_template("settings/containers/update.html", {"container": container.record}) + + async def post(self): + data = await self.request.post() + container = await self.services.container.get( + user_uid=self.session.get("uid"), uid=self.request.match_info["uid"] + ) + container['status'] = data['status'] + container['resources'] = data.get('resources', '') + container['path'] = data.get('path', '') + container['readonly'] = bool(data.get('readonly', False)) + await self.services.container.save(container) + return web.HTTPFound("/settings/containers/index.html") + +class ContainersDeleteView(BaseFormView): + + login_required = True + + async def get(self): + + container = await self.services.container.get( + user_uid=self.session.get("uid"), uid=self.request.match_info["uid"] + ) + if not container: + return web.HTTPNotFound() + + return await self.render_template("settings/containers/delete.html", {"container": container.record}) + + async def post(self): + user_uid = self.session.get("uid") + uid = self.request.match_info["uid"] + container = await self.services.container.get( + user_uid=user_uid, uid=uid + ) + if not container: + return web.HTTPNotFound() + await self.services.container.delete(user_uid=user_uid, uid=uid) + return web.HTTPFound("/settings/containers/index.html")