From 8472811913fb49fd44230f027c6f043cbc9af375 Mon Sep 17 00:00:00 2001 From: retoor Date: Sat, 8 Nov 2025 20:50:07 +0100 Subject: [PATCH] Update. --- retoors/models.py | 2 +- retoors/routes.py | 9 ++ retoors/services/user_service.py | 38 +++++++- retoors/static/css/components/order.css | 118 +++++++++++++++++++++++ retoors/static/js/main.js | 17 ++-- retoors/templates/layouts/base.html | 1 + retoors/templates/pages/order.html | 119 +++++++++++++----------- retoors/views/site.py | 44 ++++----- tests/test_site.py | 52 ----------- 9 files changed, 262 insertions(+), 138 deletions(-) diff --git a/retoors/models.py b/retoors/models.py index 6288ea8..08b5e77 100644 --- a/retoors/models.py +++ b/retoors/models.py @@ -6,4 +6,4 @@ class RegistrationModel(BaseModel): password: str = Field(min_length=8) class QuotaUpdateModel(BaseModel): - storage_amount: float = Field(gt=0, le=1000) + new_quota_gb: float = Field(gt=0, le=1000) diff --git a/retoors/routes.py b/retoors/routes.py index 378df3c..8de7a11 100644 --- a/retoors/routes.py +++ b/retoors/routes.py @@ -1,5 +1,6 @@ from .views.auth import LoginView, RegistrationView, LogoutView, ForgotPasswordView, ResetPasswordView from .views.site import SiteView, OrderView +from .views.admin import get_users, add_user, update_user_quota, delete_user, get_user_details, delete_team def setup_routes(app): @@ -16,3 +17,11 @@ def setup_routes(app): app.router.add_view("/use_cases", SiteView, name="use_cases") app.router.add_view("/dashboard", SiteView, name="dashboard") app.router.add_view("/order", OrderView, name="order") + + # Admin API routes for user and team management + app.router.add_get("/api/users", get_users, name="api_get_users") + app.router.add_post("/api/users", add_user, name="api_add_user") + app.router.add_put("/api/users/{email}/quota", update_user_quota, name="api_update_user_quota") + app.router.add_delete("/api/users/{email}", delete_user, name="api_delete_user") + app.router.add_get("/api/users/{email}", get_user_details, name="api_get_user_details") + app.router.add_delete("/api/teams/{parent_email}", delete_team, name="api_delete_team") diff --git a/retoors/services/user_service.py b/retoors/services/user_service.py index abde494..efae4d9 100644 --- a/retoors/services/user_service.py +++ b/retoors/services/user_service.py @@ -24,7 +24,13 @@ class UserService: def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]: return next((user for user in self._users if user["email"] == email), None) - def create_user(self, full_name: str, email: str, password: str) -> Dict[str, Any]: + def get_all_users(self) -> List[Dict[str, Any]]: + return self._users + + def get_users_by_parent_email(self, parent_email: str) -> List[Dict[str, Any]]: + return [user for user in self._users if user.get("parent_email") == parent_email] + + def create_user(self, full_name: str, email: str, password: str, parent_email: Optional[str] = None) -> Dict[str, Any]: if self.get_user_by_email(email): raise ValueError("User with this email already exists") @@ -38,11 +44,41 @@ class UserService: "storage_used_gb": 0, "reset_token": None, "reset_token_expiry": None, + "parent_email": parent_email, # New field for hierarchical user management } self._users.append(user) self._save_users() return user + def update_user(self, email: str, **kwargs) -> Optional[Dict[str, Any]]: + user = self.get_user_by_email(email) + if not user: + return None + + for key, value in kwargs.items(): + if key == "password": + user[key] = bcrypt.hashpw(value.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + else: + user[key] = value + self._save_users() + return user + + def delete_user(self, email: str) -> bool: + initial_len = len(self._users) + self._users = [user for user in self._users if user["email"] != email] + if len(self._users) < initial_len: + self._save_users() + return True + return False + + def delete_users_by_parent_email(self, parent_email: str) -> int: + initial_len = len(self._users) + self._users = [user for user in self._users if user.get("parent_email") != parent_email] + deleted_count = initial_len - len(self._users) + if deleted_count > 0: + self._save_users() + return deleted_count + def authenticate_user(self, email: str, password: str) -> bool: user = self.get_user_by_email(email) if not user: diff --git a/retoors/static/css/components/order.css b/retoors/static/css/components/order.css index f2bd2d2..61610d2 100644 --- a/retoors/static/css/components/order.css +++ b/retoors/static/css/components/order.css @@ -199,6 +199,7 @@ .user-quota-item .quota-actions { display: flex; + flex-wrap: wrap; /* Allow buttons to wrap */ gap: 10px; margin-top: 15px; } @@ -206,6 +207,8 @@ .user-quota-item .quota-actions .btn-outline { padding: 0.5rem 1rem; font-size: 0.85rem; + flex-grow: 1; /* Allow buttons to grow and fill space */ + min-width: 100px; /* Ensure buttons don't get too small */ } /* Small storage gauge for user items */ @@ -219,6 +222,113 @@ border-radius: 8px; } +/* Modal Styles */ +.modal { + display: none; /* Hidden by default */ + position: fixed; /* Stay in place */ + z-index: 1000; /* Sit on top */ + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + overflow: auto; /* Enable scroll if needed */ + background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ + justify-content: center; + align-items: center; +} + +.modal-content { + background-color: var(--card-background); + margin: auto; + padding: 30px; + border-radius: 8px; + box-shadow: 0 8px 25px rgba(0,0,0,0.2); + width: 90%; + max-width: 500px; + position: relative; + animation-name: animatetop; + animation-duration: 0.4s; +} + +@keyframes animatetop { + from {top: -300px; opacity: 0} + to {top: 0; opacity: 1} +} + +.modal-content h3 { + color: var(--text-color); + margin-top: 0; + margin-bottom: 20px; + font-size: 1.8rem; + text-align: center; +} + +.modal-content .form-group { + margin-bottom: 15px; +} + +.modal-content .form-group label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: var(--text-color); +} + +.modal-content .form-group input[type="text"], +.modal-content .form-group input[type="email"], +.modal-content .form-group input[type="password"], +.modal-content .form-group input[type="number"] { + width: calc(100% - 20px); + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 5px; + background-color: var(--input-background); + color: var(--text-color); + font-size: 1rem; +} + +.modal-content .btn-primary { + width: 100%; + padding: 12px; + font-size: 1.1rem; + margin-top: 20px; +} + +.modal-content .error { + color: var(--error-color); + margin-top: 10px; + text-align: center; +} + +.close-button { + color: var(--light-text-color); + position: absolute; + top: 15px; + right: 25px; + font-size: 30px; + font-weight: bold; + cursor: pointer; +} + +.close-button:hover, +.close-button:focus { + color: var(--text-color); + text-decoration: none; + cursor: pointer; +} + +/* User Details Modal Specific Styles */ +#user-details-content p { + margin-bottom: 10px; + color: var(--text-color); + font-size: 1rem; +} + +#user-details-content p strong { + color: var(--accent-color); +} + + /* Responsive adjustments */ @media (max-width: 992px) { .overview-grid { @@ -249,6 +359,10 @@ .user-quota-list { grid-template-columns: 1fr; } + .modal-content { + width: 95%; + padding: 20px; + } } @media (max-width: 480px) { @@ -278,4 +392,8 @@ .user-quotas-header h2 { font-size: 1.5rem; } + .user-quota-item .quota-actions .btn-outline { + flex-grow: unset; /* Reset flex-grow for smaller screens if needed */ + width: 100%; /* Make buttons full width */ + } } \ No newline at end of file diff --git a/retoors/static/js/main.js b/retoors/static/js/main.js index 94313b4..52b1c37 100644 --- a/retoors/static/js/main.js +++ b/retoors/static/js/main.js @@ -2,19 +2,22 @@ import './components/slider.js'; document.addEventListener('DOMContentLoaded', () => { const slider = document.querySelector('custom-slider'); - const priceDisplay = document.getElementById('price-display'); + const priceDisplay = document.getElementById('total_price'); // Corrected ID if (slider && priceDisplay) { - const pricePerGb = 0.5; // This should be fetched from the config + // pricePerGb will be read from a data attribute on the slider + const pricePerGb = parseFloat(slider.dataset.pricePerGb); const updatePrice = () => { - const value = slider.value; - const price = (value * pricePerGb).toFixed(2); - priceDisplay.textContent = `$${price}`; + const value = parseFloat(slider.value); + const totalPrice = (value * pricePerGb).toFixed(2); + priceDisplay.textContent = `$${totalPrice}`; }; + // Initial price update updatePrice(); - slider.addEventListener('value-change', updatePrice); + // Update price on slider change (using 'input' event for consistency with HTML) + slider.addEventListener('input', updatePrice); } -}); +}); \ No newline at end of file diff --git a/retoors/templates/layouts/base.html b/retoors/templates/layouts/base.html index 15b2cf0..83052a4 100644 --- a/retoors/templates/layouts/base.html +++ b/retoors/templates/layouts/base.html @@ -16,5 +16,6 @@ {% include 'components/footer.html' %} + {% block scripts %}{% endblock %} diff --git a/retoors/templates/pages/order.html b/retoors/templates/pages/order.html index 58ad832..afff668 100644 --- a/retoors/templates/pages/order.html +++ b/retoors/templates/pages/order.html @@ -32,13 +32,13 @@
- + {% if errors.storage_amount %}

{{ errors.storage_amount }}

{% endif %}
-

Estimated Price:

+

Estimated Price:

@@ -49,59 +49,72 @@

User & Department Quotas

- +
-
- {# Example User/Department Quota #} -
- -
-
-
{# Example value #} -
-

225 GB / 300 GB (75%)

-
-
- - -
-
-
- -
-
-
{# Example value #} -
-

500 GB / 1 TB (50%)

-
-
- - -
-
- {# Current user's quota #} -
- -
-
-
-
-

{{ user.storage_used_gb }} GB / {{ user.storage_quota_gb }} GB ({{ ((user.storage_used_gb / user.storage_quota_gb) * 100)|round(2) }}%)

-
-
- -
-
+
+ {# User quota items will be dynamically loaded here by JavaScript #}
+ + {# Add New User Modal #} + + + {# Edit Quota Modal #} + + + {# View Details Modal #} + + +{% endblock %} + +{% block scripts %} + {% endblock %} \ No newline at end of file diff --git a/retoors/views/site.py b/retoors/views/site.py index 038e65e..0382b9d 100644 --- a/retoors/views/site.py +++ b/retoors/views/site.py @@ -60,38 +60,34 @@ class SiteView(web.View): "pages/use_cases.html", self.request, {"request": self.request, "errors": {}, "user": self.request.get("user")} ) - async def support(self): - return aiohttp_jinja2.render_template( - "pages/support.html", self.request, {"request": self.request, "errors": {}, "user": self.request.get("user")} - ) - - async def use_cases(self): - return aiohttp_jinja2.render_template( - "pages/use_cases.html", self.request, {"request": self.request, "errors": {}, "user": self.request.get("user")} - ) - class OrderView(CustomPydanticView): template_name = "pages/order.html" @login_required async def get(self): + config_service = self.request.app["config_service"] + price_per_gb = config_service.get_price_per_gb() return aiohttp_jinja2.render_template( - self.template_name, self.request, {"request": self.request, "errors": {}, "user": self.request.get("user")} + self.template_name, self.request, { + "request": self.request, + "errors": {}, + "user": self.request.get("user"), + "price_per_gb": price_per_gb + } ) @login_required async def post(self): - try: - quota_data = QuotaUpdateModel(**await self.request.post()) - except ValidationError as e: - errors = {err["loc"][0]: err["msg"] for err in e.errors()} - return aiohttp_jinja2.render_template( - self.template_name, self.request, {"errors": errors, "request": self.request, "user": self.request.get("user")} - ) - - session = await get_session(self.request) - user_email = session.get("user_email") - user_service: UserService = self.request.app["user_service"] - user_service.update_user_quota(user_email, quota_data.storage_amount) - raise web.HTTPFound("/dashboard") + # The quota update for the main user is now handled via AJAX in order.js + # This POST method will simply re-render the page. + config_service = self.request.app["config_service"] + price_per_gb = config_service.get_price_per_gb() + return aiohttp_jinja2.render_template( + self.template_name, self.request, { + "request": self.request, + "errors": {}, + "user": self.request.get("user"), + "price_per_gb": price_per_gb + } + ) diff --git a/tests/test_site.py b/tests/test_site.py index 085abf1..f00651b 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -34,56 +34,4 @@ async def test_dashboard_get_authorized(client): assert "My Files" in text -async def test_order_post_unauthorized(client): - resp = await client.post( - "/order", data={"storage_amount": "10.5"}, allow_redirects=False - ) - assert resp.status == 302 - assert resp.headers["Location"] == "/login" - -async def test_order_post_authorized(client): - await client.post( - "/register", - data={ - "full_name": "Test User", - "email": "test@example.com", - "password": "password", - "confirm_password": "password", - }, - ) - await client.post( - "/login", data={"email": "test@example.com", "password": "password"} - ) - resp = await client.post("/order", data={"storage_amount": "10.5"}, allow_redirects=False) - assert resp.status == 302 - assert resp.headers["Location"] == "/dashboard" - - # Verify that the user's quota was updated - user_service = client.app["user_service"] - user = user_service.get_user_by_email("test@example.com") - assert user["storage_quota_gb"] == 10.5 - - -async def test_order_post_invalid_amount(client): - await client.post( - "/register", - data={ - "full_name": "Test User", - "email": "test@example.com", - "password": "password", - "confirm_password": "password", - }, - ) - await client.post( - "/login", data={"email": "test@example.com", "password": "password"} - ) - resp = await client.post("/order", data={"storage_amount": "0"}) - assert resp.status == 200 - text = await resp.text() - assert "ensure this value is greater than 0" in text - - resp = await client.post("/order", data={"storage_amount": "1001"}) - assert resp.status == 200 - text = await resp.text() - assert "ensure this value is less than or equal to 1000" in text