From 67cf0e1cea8817b2c43eefb67ca3dfdbbc89f4c3 Mon Sep 17 00:00:00 2001 From: retoor Date: Sat, 8 Nov 2025 18:20:25 +0100 Subject: [PATCH] Initial commit, working. --- retoors/MANIFEST.in | 2 + retoors/Makefile | 22 +++ retoors/pytest.ini | 3 + retoors/requirements.txt | 7 + retoors/retoors/__init__.py | 0 retoors/retoors/helpers/auth.py | 12 ++ retoors/retoors/main.py | 61 ++++++++ retoors/retoors/middlewares.py | 23 ++++ retoors/retoors/models.py | 9 ++ retoors/retoors/routes.py | 11 ++ retoors/retoors/services/__init__.py | 0 retoors/retoors/services/config_service.py | 16 +++ retoors/retoors/services/user_service.py | 54 ++++++++ retoors/retoors/static/css/base.css | 64 +++++++++ .../retoors/static/css/components/button.css | 0 .../retoors/static/css/components/custom.css | 9 ++ .../static/css/components/dashboard.css | 54 ++++++++ .../retoors/static/css/components/form.css | 22 +++ .../retoors/static/css/components/index.css | 73 ++++++++++ .../static/css/components/navigation.css | 58 ++++++++ .../retoors/static/css/components/slider.css | 18 +++ .../retoors/static/images/icon-families.svg | 1 + .../static/images/icon-professionals.svg | 1 + .../retoors/static/images/icon-students.svg | 1 + retoors/retoors/static/images/logo.svg | 4 + .../retoors/static/js/components/__init__.js | 0 .../static/js/components/navigation.js | 0 .../retoors/static/js/components/slider.js | 30 ++++ retoors/retoors/static/js/main.js | 20 +++ .../retoors/templates/components/footer.html | 3 + .../templates/components/navigation.html | 21 +++ retoors/retoors/templates/layouts/base.html | 22 +++ .../retoors/templates/pages/dashboard.html | 74 ++++++++++ .../retoors/templates/pages/errors/404.html | 8 ++ .../retoors/templates/pages/errors/500.html | 8 ++ retoors/retoors/templates/pages/index.html | 36 +++++ retoors/retoors/templates/pages/login.html | 29 ++++ retoors/retoors/templates/pages/register.html | 40 ++++++ retoors/retoors/views/__init__.py | 0 retoors/retoors/views/auth.py | 106 ++++++++++++++ retoors/retoors/views/site.py | 47 +++++++ retoors/setup.py | 17 +++ retoors/tests/conftest.py | 33 +++++ retoors/tests/test_auth.py | 130 ++++++++++++++++++ retoors/tests/test_site.py | 89 ++++++++++++ 45 files changed, 1238 insertions(+) create mode 100644 retoors/MANIFEST.in create mode 100644 retoors/Makefile create mode 100644 retoors/pytest.ini create mode 100644 retoors/requirements.txt create mode 100644 retoors/retoors/__init__.py create mode 100644 retoors/retoors/helpers/auth.py create mode 100644 retoors/retoors/main.py create mode 100644 retoors/retoors/middlewares.py create mode 100644 retoors/retoors/models.py create mode 100644 retoors/retoors/routes.py create mode 100644 retoors/retoors/services/__init__.py create mode 100644 retoors/retoors/services/config_service.py create mode 100644 retoors/retoors/services/user_service.py create mode 100644 retoors/retoors/static/css/base.css create mode 100644 retoors/retoors/static/css/components/button.css create mode 100644 retoors/retoors/static/css/components/custom.css create mode 100644 retoors/retoors/static/css/components/dashboard.css create mode 100644 retoors/retoors/static/css/components/form.css create mode 100644 retoors/retoors/static/css/components/index.css create mode 100644 retoors/retoors/static/css/components/navigation.css create mode 100644 retoors/retoors/static/css/components/slider.css create mode 100644 retoors/retoors/static/images/icon-families.svg create mode 100644 retoors/retoors/static/images/icon-professionals.svg create mode 100644 retoors/retoors/static/images/icon-students.svg create mode 100644 retoors/retoors/static/images/logo.svg create mode 100644 retoors/retoors/static/js/components/__init__.js create mode 100644 retoors/retoors/static/js/components/navigation.js create mode 100644 retoors/retoors/static/js/components/slider.js create mode 100644 retoors/retoors/static/js/main.js create mode 100644 retoors/retoors/templates/components/footer.html create mode 100644 retoors/retoors/templates/components/navigation.html create mode 100644 retoors/retoors/templates/layouts/base.html create mode 100644 retoors/retoors/templates/pages/dashboard.html create mode 100644 retoors/retoors/templates/pages/errors/404.html create mode 100644 retoors/retoors/templates/pages/errors/500.html create mode 100644 retoors/retoors/templates/pages/index.html create mode 100644 retoors/retoors/templates/pages/login.html create mode 100644 retoors/retoors/templates/pages/register.html create mode 100644 retoors/retoors/views/__init__.py create mode 100644 retoors/retoors/views/auth.py create mode 100644 retoors/retoors/views/site.py create mode 100644 retoors/setup.py create mode 100644 retoors/tests/conftest.py create mode 100644 retoors/tests/test_auth.py create mode 100644 retoors/tests/test_site.py diff --git a/retoors/MANIFEST.in b/retoors/MANIFEST.in new file mode 100644 index 0000000..7d5ae02 --- /dev/null +++ b/retoors/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-include retoors/static * +recursive-include retoors/templates * diff --git a/retoors/Makefile b/retoors/Makefile new file mode 100644 index 0000000..9f55b37 --- /dev/null +++ b/retoors/Makefile @@ -0,0 +1,22 @@ +.PHONY: install run test coverage clean all + +install: + pip install -r requirements.txt + +run: + python -m retoors.main + +test: + pytest + +coverage: + pytest --cov=retoors --cov-report=term-missing --cov-report=html + +clean: + find . -type f -name "*.pyc" -delete + find . -type d -name "__pycache__" -exec rm -rf {} + + find . -type d -name ".pytest_cache" -exec rm -rf {} + + rm -f .coverage + rm -rf htmlcov + +all: install test \ No newline at end of file diff --git a/retoors/pytest.ini b/retoors/pytest.ini new file mode 100644 index 0000000..bdfc0eb --- /dev/null +++ b/retoors/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +asyncio_mode = auto diff --git a/retoors/requirements.txt b/retoors/requirements.txt new file mode 100644 index 0000000..7ca401c --- /dev/null +++ b/retoors/requirements.txt @@ -0,0 +1,7 @@ +aiohttp +jinja2 +aiohttp_session +bcrypt +pytest +pytest-aiohttp +aiohttp-test-utils diff --git a/retoors/retoors/__init__.py b/retoors/retoors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/retoors/retoors/helpers/auth.py b/retoors/retoors/helpers/auth.py new file mode 100644 index 0000000..8949734 --- /dev/null +++ b/retoors/retoors/helpers/auth.py @@ -0,0 +1,12 @@ +from functools import wraps +from aiohttp import web +from aiohttp_session import get_session + +def login_required(func): + @wraps(func) + async def wrapper(self, *args, **kwargs): + session = await get_session(self.request) + if 'user_email' not in session: + return web.HTTPFound('/login') + return await func(self, *args, **kwargs) + return wrapper diff --git a/retoors/retoors/main.py b/retoors/retoors/main.py new file mode 100644 index 0000000..db51e3b --- /dev/null +++ b/retoors/retoors/main.py @@ -0,0 +1,61 @@ +from aiohttp import web +import aiohttp_jinja2 +import jinja2 +from pathlib import Path +import aiohttp_session +from aiohttp_session import setup as setup_session +from aiohttp_session.cookie_storage import EncryptedCookieStorage +import os + +from .routes import setup_routes +from .services.user_service import UserService +from .services.config_service import ConfigService +from .middlewares import user_middleware, error_middleware + + +async def setup_services(app: web.Application): + base_path = Path(__file__).parent + data_path = base_path.parent / "data" + app["user_service"] = UserService(data_path / "users.json") + app["config_service"] = ConfigService(data_path / "config.json") + yield + + +def create_app(): + app = web.Application() + + # The order of middleware registration matters. + # They are executed in the order they are added. + app.middlewares.append(error_middleware) + + # Setup session + secret_key = os.urandom(32) + setup_session(app, EncryptedCookieStorage(secret_key)) + + app.middlewares.append(user_middleware) + + # Setup paths + base_path = Path(__file__).parent + static_path = base_path / "static" + templates_path = base_path / "templates" + + # Setup Jinja2 + aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(str(templates_path))) + + # Setup services + app.cleanup_ctx.append(setup_services) + + # Setup routes + setup_routes(app) + app.router.add_static("/static", path=str(static_path), name="static") + + return app + + +def main(): + app = create_app() + web.run_app(app) + + +if __name__ == "__main__": + main() diff --git a/retoors/retoors/middlewares.py b/retoors/retoors/middlewares.py new file mode 100644 index 0000000..6a88c9f --- /dev/null +++ b/retoors/retoors/middlewares.py @@ -0,0 +1,23 @@ +from aiohttp import web +from aiohttp_session import get_session +import aiohttp_jinja2 + + +@web.middleware +async def user_middleware(request, handler): + session = await get_session(request) + request["user"] = None + if "user_email" in session: + user_service = request.app["user_service"] + request["user"] = user_service.get_user_by_email(session["user_email"]) + return await handler(request) + + +@web.middleware +async def error_middleware(request, handler): + try: + return await handler(request) + except web.HTTPException as ex: + raise # Re-raise HTTPException to see original traceback + except Exception: + raise # Re-raise generic Exception to see original traceback diff --git a/retoors/retoors/models.py b/retoors/retoors/models.py new file mode 100644 index 0000000..265b1f0 --- /dev/null +++ b/retoors/retoors/models.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, EmailStr, Field + +class RegistrationModel(BaseModel): + username: str = Field(min_length=3, max_length=50) + email: EmailStr + password: str = Field(min_length=8) + +class QuotaUpdateModel(BaseModel): + storage_amount: float = Field(gt=0, le=1000) diff --git a/retoors/retoors/routes.py b/retoors/retoors/routes.py new file mode 100644 index 0000000..cd6cc75 --- /dev/null +++ b/retoors/retoors/routes.py @@ -0,0 +1,11 @@ +from .views.auth import LoginView, RegistrationView, LogoutView +from .views.site import SiteView, OrderView + + +def setup_routes(app): + app.router.add_view("/login", LoginView, name="login") + app.router.add_view("/register", RegistrationView, name="register") + app.router.add_view("/logout", LogoutView, name="logout") + app.router.add_view("/", SiteView, name="index") + app.router.add_view("/dashboard", SiteView, name="dashboard") + app.router.add_view("/order", OrderView, name="order") diff --git a/retoors/retoors/services/__init__.py b/retoors/retoors/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/retoors/retoors/services/config_service.py b/retoors/retoors/services/config_service.py new file mode 100644 index 0000000..f1e0f8e --- /dev/null +++ b/retoors/retoors/services/config_service.py @@ -0,0 +1,16 @@ +import json +from pathlib import Path + +class ConfigService: + def __init__(self, config_path: Path): + self._config_path = config_path + self._config = self._load_config() + + def _load_config(self): + if not self._config_path.exists(): + return {} + with open(self._config_path, "r") as f: + return json.load(f) + + def get_price_per_gb(self) -> float: + return self._config.get("price_per_gb", 0.0) diff --git a/retoors/retoors/services/user_service.py b/retoors/retoors/services/user_service.py new file mode 100644 index 0000000..1611e2f --- /dev/null +++ b/retoors/retoors/services/user_service.py @@ -0,0 +1,54 @@ +import json +from pathlib import Path +from typing import List, Dict, Optional, Any +import hashlib + +class UserService: + def __init__(self, users_path: Path): + self._users_path = users_path + self._users = self._load_users() + + def _load_users(self) -> List[Dict[str, Any]]: + if not self._users_path.exists(): + return [] + with open(self._users_path, "r") as f: + return json.load(f) + + def _save_users(self): + with open(self._users_path, "w") as f: + json.dump(self._users, f, indent=4) + + 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, username: str, email: str, password: str) -> Dict[str, Any]: + if self.get_user_by_email(email): + raise ValueError("User with this email already exists") + + hashed_password = hashlib.sha256(password.encode()).hexdigest() + user = { + "username": username, + "email": email, + "password": hashed_password, + "storage_quota_gb": 5, # Default quota + "storage_used_gb": 0 + } + self._users.append(user) + self._save_users() + return user + + def authenticate_user(self, email: str, password: str) -> bool: + user = self.get_user_by_email(email) + if not user: + return False + hashed_password = hashlib.sha256(password.encode()).hexdigest() + return user["password"] == hashed_password + + def update_user_quota(self, email: str, new_quota_gb: float): + user = self.get_user_by_email(email) + if not user: + raise ValueError("User not found") + + user["storage_quota_gb"] = new_quota_gb + self._save_users() + diff --git a/retoors/retoors/static/css/base.css b/retoors/retoors/static/css/base.css new file mode 100644 index 0000000..d528d6b --- /dev/null +++ b/retoors/retoors/static/css/base.css @@ -0,0 +1,64 @@ +:root { + --primary-color: #0A2540; + --secondary-color: #6c757d; + --background-color: #F8F5F2; + --text-color: #343a40; + --container-bg-color: #ffffff; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + margin: 0; + background-color: var(--background-color); + color: var(--text-color); +} + +main { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; +} + +section { + background-color: var(--container-bg-color); + padding: 2rem; + border-radius: 8px; + margin-bottom: 2rem; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.hero { + text-align: center; + background-color: transparent; + color: var(--primary-color); + box-shadow: none; +} + +.hero h1 { + font-size: 2.5rem; + margin-bottom: 1rem; +} + +.hero p { + font-size: 1.2rem; +} + +.btn-primary { + display: inline-block; + background-color: var(--primary-color); + color: white; + padding: 0.75rem 1.5rem; + border-radius: 8px; + text-decoration: none; + font-weight: bold; + border: none; + cursor: pointer; +} + +footer { + text-align: center; + padding: 1rem; + margin-top: 2rem; + font-size: 0.9rem; + color: var(--secondary-color); +} diff --git a/retoors/retoors/static/css/components/button.css b/retoors/retoors/static/css/components/button.css new file mode 100644 index 0000000..e69de29 diff --git a/retoors/retoors/static/css/components/custom.css b/retoors/retoors/static/css/components/custom.css new file mode 100644 index 0000000..dbd6f2f --- /dev/null +++ b/retoors/retoors/static/css/components/custom.css @@ -0,0 +1,9 @@ +.error { + color: #dc3545; + background-color: #f8d7da; + border-color: #f5c6cb; + padding: .75rem 1.25rem; + margin-bottom: 1rem; + border: 1px solid transparent; + border-radius: .25rem; +} diff --git a/retoors/retoors/static/css/components/dashboard.css b/retoors/retoors/static/css/components/dashboard.css new file mode 100644 index 0000000..727bfcd --- /dev/null +++ b/retoors/retoors/static/css/components/dashboard.css @@ -0,0 +1,54 @@ +.dashboard-header { + text-align: center; + margin-bottom: 2rem; +} + +.storage-overview { + margin-bottom: 2rem; +} + +.storage-gauge { + background-color: #e9ecef; + border-radius: .25rem; + height: 20px; + margin-bottom: 0.5rem; +} + +.storage-gauge-bar { + background-color: var(--primary-color); + height: 100%; + border-radius: .25rem; +} + +.storage-info { + display: flex; + justify-content: space-between; + font-size: 0.9rem; + color: var(--secondary-color); +} + +.file-list table { + width: 100%; + border-collapse: collapse; +} + +.file-list th, .file-list td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid #dee2e6; +} + +.file-list th { + background-color: #f8f9fa; +} + +.order-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.price-display { + font-size: 1.2rem; + font-weight: bold; +} diff --git a/retoors/retoors/static/css/components/form.css b/retoors/retoors/static/css/components/form.css new file mode 100644 index 0000000..64ded22 --- /dev/null +++ b/retoors/retoors/static/css/components/form.css @@ -0,0 +1,22 @@ +.form-container { + max-width: 500px; + margin: 0 auto; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid #ced4da; + border-radius: var(--border-radius); + font-size: 1rem; +} diff --git a/retoors/retoors/static/css/components/index.css b/retoors/retoors/static/css/components/index.css new file mode 100644 index 0000000..8db6316 --- /dev/null +++ b/retoors/retoors/static/css/components/index.css @@ -0,0 +1,73 @@ +.hero h1 { + font-size: 3rem; + font-weight: bold; +} + +.hero p { + font-size: 1.25rem; + color: #555; +} + +.features { + display: flex; + justify-content: space-around; + gap: 2rem; + background-color: transparent; + box-shadow: none; + padding: 0; +} + +.feature-card { + background-color: #fff; + border-radius: 12px; + padding: 2rem; + text-align: center; + flex: 1; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + transition: transform 0.2s; +} + +.feature-card:hover { + transform: translateY(-5px); +} + +.feature-card img { + height: 48px; + margin-bottom: 1rem; +} + +.feature-card h2 { + font-size: 1.5rem; + margin-bottom: 1rem; + color: var(--primary-color); +} + +.feature-card p { + color: #555; +} + +#families { + background-color: #E0F7FA; +} + +#professionals { + background-color: #FFECB3; +} + +#students { + background-color: #E8F5E9; +} + +.cta { + text-align: center; + background-color: transparent; + box-shadow: none; + padding-top: 2rem; +} + +.cta .btn-primary { + background-color: #007BFF; + padding: 1rem 2rem; + font-size: 1.2rem; + border-radius: 8px; +} diff --git a/retoors/retoors/static/css/components/navigation.css b/retoors/retoors/static/css/components/navigation.css new file mode 100644 index 0000000..1c05283 --- /dev/null +++ b/retoors/retoors/static/css/components/navigation.css @@ -0,0 +1,58 @@ +header { + background-color: var(--container-bg-color); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + padding: 1rem 2rem; + border-bottom: 1px solid #EAEAEA; +} + +nav { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + margin: 0 auto; +} + +nav .logo { + display: flex; + align-items: center; + font-size: 1.2rem; + font-weight: bold; + color: var(--primary-color); + text-decoration: none; +} + +nav .logo img { + height: 32px; + margin-right: 0.5rem; +} + +nav ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + align-items: center; +} + +nav ul li { + margin-left: 2rem; +} + +nav ul li a { + text-decoration: none; + color: var(--text-color); + font-weight: 500; +} + +nav ul li a.btn-nav { + background-color: #007BFF; + color: white; + padding: 0.5rem 1rem; + border-radius: 5px; + border: 1px solid #007BFF; +} + +nav ul li a.btn-nav:hover { + background-color: #0056b3; +} diff --git a/retoors/retoors/static/css/components/slider.css b/retoors/retoors/static/css/components/slider.css new file mode 100644 index 0000000..f713f17 --- /dev/null +++ b/retoors/retoors/static/css/components/slider.css @@ -0,0 +1,18 @@ +custom-slider { + display: block; + width: 100%; +} + +custom-slider .slider-container { + display: flex; + align-items: center; + gap: 1rem; +} + +custom-slider input[type="range"] { + flex-grow: 1; +} + +custom-slider .slider-value { + font-weight: bold; +} diff --git a/retoors/retoors/static/images/icon-families.svg b/retoors/retoors/static/images/icon-families.svg new file mode 100644 index 0000000..4ca4565 --- /dev/null +++ b/retoors/retoors/static/images/icon-families.svg @@ -0,0 +1 @@ + diff --git a/retoors/retoors/static/images/icon-professionals.svg b/retoors/retoors/static/images/icon-professionals.svg new file mode 100644 index 0000000..d6e66bc --- /dev/null +++ b/retoors/retoors/static/images/icon-professionals.svg @@ -0,0 +1 @@ + diff --git a/retoors/retoors/static/images/icon-students.svg b/retoors/retoors/static/images/icon-students.svg new file mode 100644 index 0000000..2e70db7 --- /dev/null +++ b/retoors/retoors/static/images/icon-students.svg @@ -0,0 +1 @@ + diff --git a/retoors/retoors/static/images/logo.svg b/retoors/retoors/static/images/logo.svg new file mode 100644 index 0000000..b14c510 --- /dev/null +++ b/retoors/retoors/static/images/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/retoors/retoors/static/js/components/__init__.js b/retoors/retoors/static/js/components/__init__.js new file mode 100644 index 0000000..e69de29 diff --git a/retoors/retoors/static/js/components/navigation.js b/retoors/retoors/static/js/components/navigation.js new file mode 100644 index 0000000..e69de29 diff --git a/retoors/retoors/static/js/components/slider.js b/retoors/retoors/static/js/components/slider.js new file mode 100644 index 0000000..0e335d3 --- /dev/null +++ b/retoors/retoors/static/js/components/slider.js @@ -0,0 +1,30 @@ +class CustomSlider extends HTMLElement { + constructor() { + super(); + this.min = this.getAttribute('min') || 0; + this.max = this.getAttribute('max') || 100; + this.value = this.getAttribute('value') || 50; + this.step = this.getAttribute('step') || 1; + this.name = this.getAttribute('name') || 'slider'; + } + + connectedCallback() { + this.innerHTML = ` +
+ + ${this.value} GB +
+ `; + + this.input = this.querySelector('input[type="range"]'); + this.valueDisplay = this.querySelector('.slider-value'); + + this.input.addEventListener('input', () => { + this.value = this.input.value; + this.valueDisplay.textContent = `${this.value} GB`; + this.dispatchEvent(new CustomEvent('value-change', { detail: { value: this.value } })); + }); + } +} + +customElements.define('custom-slider', CustomSlider); diff --git a/retoors/retoors/static/js/main.js b/retoors/retoors/static/js/main.js new file mode 100644 index 0000000..94313b4 --- /dev/null +++ b/retoors/retoors/static/js/main.js @@ -0,0 +1,20 @@ +import './components/slider.js'; + +document.addEventListener('DOMContentLoaded', () => { + const slider = document.querySelector('custom-slider'); + const priceDisplay = document.getElementById('price-display'); + + if (slider && priceDisplay) { + const pricePerGb = 0.5; // This should be fetched from the config + + const updatePrice = () => { + const value = slider.value; + const price = (value * pricePerGb).toFixed(2); + priceDisplay.textContent = `$${price}`; + }; + + updatePrice(); + + slider.addEventListener('value-change', updatePrice); + } +}); diff --git a/retoors/retoors/templates/components/footer.html b/retoors/retoors/templates/components/footer.html new file mode 100644 index 0000000..75a2b72 --- /dev/null +++ b/retoors/retoors/templates/components/footer.html @@ -0,0 +1,3 @@ + diff --git a/retoors/retoors/templates/components/navigation.html b/retoors/retoors/templates/components/navigation.html new file mode 100644 index 0000000..c38d36e --- /dev/null +++ b/retoors/retoors/templates/components/navigation.html @@ -0,0 +1,21 @@ +
+ +
diff --git a/retoors/retoors/templates/layouts/base.html b/retoors/retoors/templates/layouts/base.html new file mode 100644 index 0000000..65cc465 --- /dev/null +++ b/retoors/retoors/templates/layouts/base.html @@ -0,0 +1,22 @@ + + + + + + {% block title %}Retoors Storage{% endblock %} + + + {% block head %}{% endblock %} + + + {% include "components/navigation.html" %} + +
+ {% block content %}{% endblock %} +
+ + {% include "components/footer.html" %} + + + + diff --git a/retoors/retoors/templates/pages/dashboard.html b/retoors/retoors/templates/pages/dashboard.html new file mode 100644 index 0000000..c202402 --- /dev/null +++ b/retoors/retoors/templates/pages/dashboard.html @@ -0,0 +1,74 @@ +{% extends "layouts/base.html" %} + +{% block title %}Dashboard{% endblock %} + +{% block head %} + + +{% endblock %} + +{% block content %} +
+

Welcome, {{ user.email }}

+
+ +
+

Storage Overview

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

My Files

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
NameSizeLast Modified
documents.zip256 MB2025-11-08
photos.zip1.2 GB2025-11-07
project_alpha.zip512 MB2025-11-05
+
+
+ +
+

Order More Storage

+
+
+ + + {% if errors.storage_amount %} +

{{ errors.storage_amount }}

+ {% endif %} +
+
+

Price:

+
+ +
+
+{% endblock %} diff --git a/retoors/retoors/templates/pages/errors/404.html b/retoors/retoors/templates/pages/errors/404.html new file mode 100644 index 0000000..e4d14ce --- /dev/null +++ b/retoors/retoors/templates/pages/errors/404.html @@ -0,0 +1,8 @@ +{% extends "layouts/base.html" %} + +{% block content %} +
+

404 - Page Not Found

+

The page you are looking for does not exist.

+
+{% endblock %} diff --git a/retoors/retoors/templates/pages/errors/500.html b/retoors/retoors/templates/pages/errors/500.html new file mode 100644 index 0000000..03bfc67 --- /dev/null +++ b/retoors/retoors/templates/pages/errors/500.html @@ -0,0 +1,8 @@ +{% extends "layouts/base.html" %} + +{% block content %} +
+

500 - Internal Server Error

+

Something went wrong on our end. Please try again later.

+
+{% endblock %} diff --git a/retoors/retoors/templates/pages/index.html b/retoors/retoors/templates/pages/index.html new file mode 100644 index 0000000..9a24224 --- /dev/null +++ b/retoors/retoors/templates/pages/index.html @@ -0,0 +1,36 @@ +{% extends "layouts/base.html" %} + +{% block title %}Welcome to Retoors Storage{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+

Solutions for Everyone

+

Securely store and manage your data with HomeBase Storage.

+
+ +
+
+ Families Icon +

For Families

+

Securely backup and share precious photos and videos. Keep memories safe for generations.

+
+
+ Professionals Icon +

For Professionals

+

Organize important work documents, collaborate with teams, and access files from anywhere.

+
+
+ Students Icon +

For Students

+

Store projects, notes, and research papers. Access study materials across your devices.

+
+
+ +
+ Find Your Perfect Plan +
+{% endblock %} diff --git a/retoors/retoors/templates/pages/login.html b/retoors/retoors/templates/pages/login.html new file mode 100644 index 0000000..f96edb4 --- /dev/null +++ b/retoors/retoors/templates/pages/login.html @@ -0,0 +1,29 @@ +{% extends "layouts/base.html" %} + +{% block title %}Login{% endblock %} + +{% block content %} +
+

Login to your Account

+ {% if error %} +

{{ error }}

+ {% endif %} +
+
+ + + {% if errors.email %} +

{{ errors.email }}

+ {% endif %} +
+
+
+ +
+
+{% endblock %} diff --git a/retoors/retoors/templates/pages/register.html b/retoors/retoors/templates/pages/register.html new file mode 100644 index 0000000..39f7830 --- /dev/null +++ b/retoors/retoors/templates/pages/register.html @@ -0,0 +1,40 @@ +{% extends "layouts/base.html" %} + +{% block title %}Register{% endblock %} + +{% block content %} +
+

Create an Account

+ {% if error %} +

{{ error }}

+ {% endif %} +
+
+ + + {% if errors.username %} +

{{ errors.username }}

+ {% endif %} +
+
+ + + {% if errors.email %} +

{{ errors.email }}

+ {% endif %} +
+
+ + + {% if errors.password %} +

{{ errors.password }}

+ {% endif %} +
+
+ + +
+ +
+
+{% endblock %} diff --git a/retoors/retoors/views/__init__.py b/retoors/retoors/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/retoors/retoors/views/auth.py b/retoors/retoors/views/auth.py new file mode 100644 index 0000000..2f5cd55 --- /dev/null +++ b/retoors/retoors/views/auth.py @@ -0,0 +1,106 @@ +from aiohttp import web +import aiohttp_jinja2 +from aiohttp_session import get_session, new_session +from aiohttp_pydantic import PydanticView +from pydantic import BaseModel, EmailStr, Field, ValidationError + +from ..services.user_service import UserService +from ..models import RegistrationModel + + +class LoginModel(BaseModel): + email: EmailStr + password: str + + +class CustomPydanticView(PydanticView): + template_name: str = "" + + async def on_validation_error( + self, exception: ValidationError, context: str + ): + errors = { + err["loc"][0]: err["msg"] for err in exception.errors() + } + return aiohttp_jinja2.render_template( + self.template_name, self.request, {"errors": errors, "request": self.request} + ) + + +class LoginView(CustomPydanticView): + template_name = "pages/login.html" + + async def get(self): + return aiohttp_jinja2.render_template( + self.template_name, self.request, {"request": self.request, "errors": {}} + ) + + async def post(self): + try: + login_data = LoginModel(**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_service: UserService = self.request.app["user_service"] + if user_service.authenticate_user(login_data.email, login_data.password): + session = await new_session(self.request) + session["user_email"] = login_data.email + return web.HTTPFound("/dashboard") + + return aiohttp_jinja2.render_template( + self.template_name, + self.request, + {"error": "Invalid email or password", "request": self.request, "errors": {}}, + ) + + +class RegistrationView(CustomPydanticView): + template_name = "pages/register.html" + + async def get(self): + return aiohttp_jinja2.render_template( + self.template_name, self.request, {"request": self.request, "errors": {}} + ) + + async def post(self): + try: + user_data = RegistrationModel(**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} + ) + + data = await self.request.post() + if user_data.password != data.get("confirm_password"): + return aiohttp_jinja2.render_template( + self.template_name, + self.request, + {"error": "Passwords do not match", "request": self.request, "errors": {}}, + ) + + user_service: UserService = self.request.app["user_service"] + try: + user_service.create_user(user_data.username, user_data.email, user_data.password) + except ValueError: + return aiohttp_jinja2.render_template( + self.template_name, + self.request, + { + "error": "User with this email already exists", + "request": self.request, + "errors": {}, + }, + ) + + return web.HTTPFound("/login") + + +class LogoutView(web.View): + async def get(self): + session = await get_session(self.request) + session.clear() + return web.HTTPFound("/") diff --git a/retoors/retoors/views/site.py b/retoors/retoors/views/site.py new file mode 100644 index 0000000..26fc4ab --- /dev/null +++ b/retoors/retoors/views/site.py @@ -0,0 +1,47 @@ +from aiohttp import web +import aiohttp_jinja2 +from aiohttp_session import get_session +from pydantic import ValidationError + +from ..services.user_service import UserService +from ..helpers.auth import login_required +from ..models import QuotaUpdateModel +from .auth import CustomPydanticView + + +class SiteView(web.View): + async def get(self): + if self.request.path == "/dashboard": + return await self.dashboard() + return aiohttp_jinja2.render_template( + "pages/index.html", self.request, {"request": self.request, "errors": {}, "user": self.request.get("user")} + ) + + @login_required + async def dashboard(self): + return aiohttp_jinja2.render_template( + "pages/dashboard.html", + self.request, + {"user": self.request["user"], "request": self.request, "errors": {}}, + ) + + +class OrderView(CustomPydanticView): + template_name = "pages/dashboard.html" + + @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) + + return web.HTTPFound("/dashboard") diff --git a/retoors/setup.py b/retoors/setup.py new file mode 100644 index 0000000..12dfa25 --- /dev/null +++ b/retoors/setup.py @@ -0,0 +1,17 @@ +from setuptools import setup, find_packages + +setup( + name="retoors", + version="0.1.0", + packages=find_packages(), + include_package_data=True, + install_requires=[ + "aiohttp", + "jinja2", + ], + entry_points={ + "console_scripts": [ + "retoors=retoors.main:main", + ], + }, +) diff --git a/retoors/tests/conftest.py b/retoors/tests/conftest.py new file mode 100644 index 0000000..588be1c --- /dev/null +++ b/retoors/tests/conftest.py @@ -0,0 +1,33 @@ +import pytest +from pathlib import Path +import json +from retoors.main import create_app +from retoors.services.user_service import UserService +from retoors.services.config_service import ConfigService + + +@pytest.fixture +def client(event_loop, aiohttp_client): + app = create_app() + + # Create temporary data files for testing + base_path = Path(__file__).parent.parent + data_path = base_path / "data" + data_path.mkdir(exist_ok=True) + + users_file = data_path / "users.json" + with open(users_file, "w") as f: + json.dump([], f) + + config_file = data_path / "config.json" + with open(config_file, "w") as f: + json.dump({"price_per_gb": 0.0}, f) + + app["user_service"] = UserService(users_file) + app["config_service"] = ConfigService(config_file) + + yield event_loop.run_until_complete(aiohttp_client(app)) + + # Clean up temporary files + users_file.unlink(missing_ok=True) + config_file.unlink(missing_ok=True) diff --git a/retoors/tests/test_auth.py b/retoors/tests/test_auth.py new file mode 100644 index 0000000..ea18489 --- /dev/null +++ b/retoors/tests/test_auth.py @@ -0,0 +1,130 @@ +import pytest +from aiohttp import web + + +async def test_login_get(client): + resp = await client.get("/login") + assert resp.status == 200 + text = await resp.text() + assert "Login to your Account" in text + + +async def test_register_get(client): + resp = await client.get("/register") + assert resp.status == 200 + text = await resp.text() + assert "Create an Account" in text + assert resp.url.path == "/register" + + +async def test_register_post_password_mismatch(client): + resp = await client.post( + "/register", + data={ + "username": "testuser", + "email": "test@example.com", + "password": "password", + "confirm_password": "wrong_password", + }, + ) + assert resp.status == 200 + text = await resp.text() + assert "Passwords do not match" in text + + +async def test_register_post_user_exists(client): + await client.post( + "/register", + data={ + "username": "testuser", + "email": "test@example.com", + "password": "password", + "confirm_password": "password", + }, + ) + resp = await client.post( + "/register", + data={ + "username": "testuser2", + "email": "test@example.com", + "password": "password", + "confirm_password": "password", + }, + ) + assert resp.status == 200 + text = await resp.text() + assert "User with this email already exists" in text + + +async def test_register_post_invalid_email(client): + resp = await client.post( + "/register", + data={ + "username": "testuser", + "email": "invalid-email", + "password": "password", + "confirm_password": "password", + }, + ) + assert resp.status == 200 + text = await resp.text() + assert "value is not a valid email address" in text + + +async def test_register_post_short_password(client): + resp = await client.post( + "/register", + data={ + "username": "testuser", + "email": "test@example.com", + "password": "short", + "confirm_password": "short", + }, + ) + assert resp.status == 200 + text = await resp.text() + assert "ensure this value has at least 8 characters" in text + + +async def test_login_post(client): + await client.post( + "/register", + data={ + "username": "testuser", + "email": "test@example.com", + "password": "password", + "confirm_password": "password", + }, + ) + resp = await client.post( + "/login", data={"email": "test@example.com", "password": "password"}, allow_redirects=False + ) + assert resp.status == 302 + assert resp.headers["Location"] == "/dashboard" + + +async def test_login_post_invalid_credentials(client): + resp = await client.post( + "/login", data={"email": "test@example.com", "password": "wrong_password"} + ) + assert resp.status == 200 + text = await resp.text() + assert "Invalid email or password" in text + + +async def test_logout(client): + await client.post( + "/register", + data={ + "username": "testuser", + "email": "test@example.com", + "password": "password", + "confirm_password": "password", + }, + ) + await client.post( + "/login", data={"email": "test@example.com", "password": "password"} + ) + resp = await client.get("/logout", allow_redirects=False) + assert resp.status == 302 + assert resp.headers["Location"] == "/" diff --git a/retoors/tests/test_site.py b/retoors/tests/test_site.py new file mode 100644 index 0000000..618989e --- /dev/null +++ b/retoors/tests/test_site.py @@ -0,0 +1,89 @@ +import pytest +from aiohttp import web + + +async def test_index_get(client): + resp = await client.get("/") + assert resp.status == 200 + text = await resp.text() + assert "Solutions for Everyone" in text + + +async def test_dashboard_get_unauthorized(client): + resp = await client.get("/dashboard", allow_redirects=False) + assert resp.status == 302 + assert resp.headers["Location"] == "/login" + + +async def test_dashboard_get_authorized(client): + await client.post( + "/register", + data={ + "username": "testuser", + "email": "test@example.com", + "password": "password", + "confirm_password": "password", + }, + ) + await client.post( + "/login", data={"email": "test@example.com", "password": "password"} + ) + resp = await client.get("/dashboard") + assert resp.status == 200 + text = await resp.text() + assert "Welcome, test@example.com" 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={ + "username": "testuser", + "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={ + "username": "testuser", + "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