Initial commit, working.

This commit is contained in:
retoor 2025-11-08 18:20:25 +01:00
commit 67cf0e1cea
45 changed files with 1238 additions and 0 deletions

2
retoors/MANIFEST.in Normal file
View File

@ -0,0 +1,2 @@
recursive-include retoors/static *
recursive-include retoors/templates *

22
retoors/Makefile Normal file
View File

@ -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

3
retoors/pytest.ini Normal file
View File

@ -0,0 +1,3 @@
[pytest]
pythonpath = .
asyncio_mode = auto

7
retoors/requirements.txt Normal file
View File

@ -0,0 +1,7 @@
aiohttp
jinja2
aiohttp_session
bcrypt
pytest
pytest-aiohttp
aiohttp-test-utils

View File

View File

@ -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

61
retoors/retoors/main.py Normal file
View File

@ -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()

View File

@ -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

View File

@ -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)

11
retoors/retoors/routes.py Normal file
View File

@ -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")

View File

View File

@ -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)

View File

@ -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()

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#0A2540" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-users"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>

After

Width:  |  Height:  |  Size: 396 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#0A2540" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file-text"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>

After

Width:  |  Height:  |  Size: 469 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#0A2540" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-award"><circle cx="12" cy="8" r="7"></circle><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"></polyline></svg>

After

Width:  |  Height:  |  Size: 321 B

View File

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 0L0 12V32H32V12L16 0Z" fill="#0A2540"/>
<path d="M24 24H8V12H24V24Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 200 B

View File

@ -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 = `
<div class="slider-container">
<input type="range" min="${this.min}" max="${this.max}" value="${this.value}" step="${this.step}" name="${this.name}">
<span class="slider-value">${this.value} GB</span>
</div>
`;
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);

View File

@ -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);
}
});

View File

@ -0,0 +1,3 @@
<footer>
<p>&copy; 2025 Retoors. All rights reserved.</p>
</footer>

View File

@ -0,0 +1,21 @@
<header>
<nav>
<a href="/" class="logo">
<img src="/static/images/logo.svg" alt="HomeBase Storage" />
<span>HomeBase Storage</span>
</a>
<ul>
<li><a href="#">Features</a></li>
<li><a href="#">Pricing</a></li>
<li><a href="#">Reviews</a></li>
<li><a href="#">Support</a></li>
{% if request['user'] %}
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/logout" class="btn-nav">Logout</a></li>
{% else %}
<li><a href="/login">Login</a></li>
<li><a href="/register" class="btn-nav">Start Your Free Trial</a></li>
{% endif %}
</ul>
</nav>
</header>

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Retoors Storage{% endblock %}</title>
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/components/custom.css">
{% block head %}{% endblock %}
</head>
<body>
{% include "components/navigation.html" %}
<main>
{% block content %}{% endblock %}
</main>
{% include "components/footer.html" %}
<script src="/static/js/main.js" type="module"></script>
</body>
</html>

View File

@ -0,0 +1,74 @@
{% extends "layouts/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block head %}
<link rel="stylesheet" href="/static/css/components/slider.css">
<link rel="stylesheet" href="/static/css/components/dashboard.css">
{% endblock %}
{% block content %}
<section class="dashboard-header">
<h2>Welcome, {{ user.email }}</h2>
</section>
<section class="storage-overview">
<h3>Storage Overview</h3>
<div class="storage-gauge">
<div class="storage-gauge-bar" style="width: {{ (user.storage_used_gb / user.storage_quota_gb) * 100 }}%;"></div>
</div>
<div class="storage-info">
<span>{{ user.storage_used_gb }} GB used of {{ user.storage_quota_gb }} GB</span>
<span>{{ ((user.storage_used_gb / user.storage_quota_gb) * 100)|round(2) }}%</span>
</div>
</section>
<section>
<h2>My Files</h2>
<div class="file-list">
<table>
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Last Modified</th>
</tr>
</thead>
<tbody>
<tr>
<td>documents.zip</td>
<td>256 MB</td>
<td>2025-11-08</td>
</tr>
<tr>
<td>photos.zip</td>
<td>1.2 GB</td>
<td>2025-11-07</td>
</tr>
<tr>
<td>project_alpha.zip</td>
<td>512 MB</td>
<td>2025-11-05</td>
</tr>
</tbody>
</table>
</div>
</section>
<section>
<h2>Order More Storage</h2>
<form action="/order" method="post" class="order-form">
<div class="form-group">
<label for="storage_amount">Storage (GB)</label>
<custom-slider min="5" max="1000" value="{{ user.storage_quota_gb }}" step="5" name="storage_amount"></custom-slider>
{% if errors.storage_amount %}
<p class="error">{{ errors.storage_amount }}</p>
{% endif %}
</div>
<div class="price-display">
<p>Price: <span id="price-display"></span></p>
</div>
<button type="submit" class="btn-primary">Order</button>
</form>
</section>
{% endblock %}

View File

@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% block content %}
<div class="container">
<h1>404 - Page Not Found</h1>
<p>The page you are looking for does not exist.</p>
</div>
{% endblock %}

View File

@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% block content %}
<div class="container">
<h1>500 - Internal Server Error</h1>
<p>Something went wrong on our end. Please try again later.</p>
</div>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends "layouts/base.html" %}
{% block title %}Welcome to Retoors Storage{% endblock %}
{% block head %}
<link rel="stylesheet" href="/static/css/components/index.css">
{% endblock %}
{% block content %}
<section class="hero">
<h1>Solutions for Everyone</h1>
<p>Securely store and manage your data with HomeBase Storage.</p>
</section>
<section class="features">
<div class="feature-card" id="families">
<img src="/static/images/icon-families.svg" alt="Families Icon">
<h2>For Families</h2>
<p>Securely backup and share precious photos and videos. Keep memories safe for generations.</p>
</div>
<div class="feature-card" id="professionals">
<img src="/static/images/icon-professionals.svg" alt="Professionals Icon">
<h2>For Professionals</h2>
<p>Organize important work documents, collaborate with teams, and access files from anywhere.</p>
</div>
<div class="feature-card" id="students">
<img src="/static/images/icon-students.svg" alt="Students Icon">
<h2>For Students</h2>
<p>Store projects, notes, and research papers. Access study materials across your devices.</p>
</div>
</section>
<section class="cta">
<a href="/register" class="btn-primary">Find Your Perfect Plan</a>
</section>
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "layouts/base.html" %}
{% block title %}Login{% endblock %}
{% block content %}
<section class="form-container">
<h2>Login to your Account</h2>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form action="/login" method="post">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
{% if errors.email %}
<p class="error">{{ errors.email }}</p>
{% endif %}
</div>
<div class="form-group">
<label for="password">Password</.label>
<input type="password" id="password" name="password" required>
{% if errors.password %}
<p class="error">{{ errors.password }}</p>
{% endif %}
</div>
<button type="submit" class="btn-primary">Login</button>
</form>
</section>
{% endblock %}

View File

@ -0,0 +1,40 @@
{% extends "layouts/base.html" %}
{% block title %}Register{% endblock %}
{% block content %}
<section class="form-container">
<h2>Create an Account</h2>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form action="/register" method="post">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
{% if errors.username %}
<p class="error">{{ errors.username }}</p>
{% endif %}
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
{% if errors.email %}
<p class="error">{{ errors.email }}</p>
{% endif %}
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
{% if errors.password %}
<p class="error">{{ errors.password }}</p>
{% endif %}
</div>
<div class="form-group">
<label for="confirm_password">Confirm Password</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<button type="submit" class="btn-primary">Register</button>
</form>
</section>
{% endblock %}

View File

View File

@ -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("/")

View File

@ -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")

17
retoors/setup.py Normal file
View File

@ -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",
],
},
)

33
retoors/tests/conftest.py Normal file
View File

@ -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)

130
retoors/tests/test_auth.py Normal file
View File

@ -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"] == "/"

View File

@ -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