This commit is contained in:
retoor 2025-11-08 20:50:07 +01:00
parent a7d8613dd6
commit 8472811913
9 changed files with 262 additions and 138 deletions

View File

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

View File

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

View File

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

View File

@ -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 */
}
}

View File

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

View File

@ -16,5 +16,6 @@
</div>
{% include 'components/footer.html' %}
<script src="/static/js/main.js" type="module"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -32,13 +32,13 @@
<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>
<custom-slider min="5" max="1000" value="{{ user.storage_quota_gb }}" step="5" name="storage_amount" data-price-per-gb="{{ price_per_gb | tojson }}"></custom-slider>
{% if errors.storage_amount %}
<p class="error">{{ errors.storage_amount }}</p>
{% endif %}
</div>
<div class="price-display">
<p>Estimated Price: <span id="price-display"></span></p>
<p>Estimated Price: <span id="total_price"></span></p>
</div>
<button type="submit" class="btn-primary">Update Quota</button>
</form>
@ -49,59 +49,72 @@
<section class="user-quotas-section">
<div class="user-quotas-header">
<h2>User & Department Quotas</h2>
<button class="btn-primary">+ Add New User</button>
</div>
<div class="user-quota-list">
{# Example User/Department Quota #}
<div class="user-quota-item card">
<div class="user-info">
<h4>John S.</h4>
<p>john.s@retoors.com</p>
</div>
<div class="quota-details">
<div class="storage-gauge small">
<div class="storage-gauge-bar" style="width: 75%;"></div> {# Example value #}
</div>
<p>225 GB / 300 GB (75%)</p>
</div>
<div class="quota-actions">
<button class="btn-outline">Edit Quota</button>
<button class="btn-outline">Delete User</button>
</div>
</div>
<div class="user-quota-item card">
<div class="user-info">
<h4>Marketing Team</h4>
<p>marketing@retoors.com</p>
</div>
<div class="quota-details">
<div class="storage-gauge small">
<div class="storage-gauge-bar" style="width: 50%;"></div> {# Example value #}
</div>
<p>500 GB / 1 TB (50%)</p>
</div>
<div class="quota-actions">
<button class="btn-outline">Edit Quota</button>
<button class="btn-outline">Delete Team</button>
</div>
</div>
{# Current user's quota #}
<div class="user-quota-item card">
<div class="user-info">
<h4>{{ user.email }} (You)</h4>
<p>Individual Quota</p>
</div>
<div class="quota-details">
<div class="storage-gauge small">
<div class="storage-gauge-bar" style="width: {{ (user.storage_used_gb / user.storage_quota_gb) * 100 }}%;"></div>
</div>
<p>{{ user.storage_used_gb }} GB / {{ user.storage_quota_gb }} GB ({{ ((user.storage_used_gb / user.storage_quota_gb) * 100)|round(2) }}%)</p>
</div>
<div class="quota-actions">
<button class="btn-outline">View Details</button>
</div>
<button class="btn-primary" id="add-new-user-btn">+ Add New User</button>
</div>
<div class="user-quota-list" id="user-quota-list">
{# User quota items will be dynamically loaded here by JavaScript #}
</div>
</section>
{# Add New User Modal #}
<div id="add-user-modal" class="modal">
<div class="modal-content">
<span class="close-button">&times;</span>
<h3>Add New User</h3>
<form id="add-user-form">
<div class="form-group">
<label for="new-user-full-name">Full Name</label>
<input type="text" id="new-user-full-name" name="full_name" required>
</div>
<div class="form-group">
<label for="new-user-email">Email</label>
<input type="email" id="new-user-email" name="email" required>
</div>
<div class="form-group">
<label for="new-user-password">Password</label>
<input type="password" id="new-user-password" name="password" required>
</div>
<div class="form-group">
<label for="new-user-confirm-password">Confirm Password</label>
<input type="password" id="new-user-confirm-password" name="confirm_password" required>
</div>
<button type="submit" class="btn-primary">Add User</button>
<p id="add-user-message" class="error"></p>
</form>
</div>
</div>
{# Edit Quota Modal #}
<div id="edit-quota-modal" class="modal">
<div class="modal-content">
<span class="close-button">&times;</span>
<h3>Edit User Quota</h3>
<form id="edit-quota-form">
<input type="hidden" id="edit-quota-user-email">
<div class="form-group">
<label for="edit-quota-amount">Storage Quota (GB)</label>
<input type="number" id="edit-quota-amount" name="new_quota_gb" min="1" required>
</div>
<button type="submit" class="btn-primary">Update Quota</button>
<p id="edit-quota-message" class="error"></p>
</form>
</div>
</div>
{# View Details Modal #}
<div id="view-details-modal" class="modal">
<div class="modal-content">
<span class="close-button">&times;</span>
<h3>User Details</h3>
<div id="user-details-content">
{# User details will be loaded here #}
</div>
</div>
</div>
</main>
{% endblock %}
{% block scripts %}
<script src="/static/js/components/order.js"></script>
{% endblock %}

View File

@ -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()}
# 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, {"errors": errors, "request": self.request, "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
}
)
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")

View File

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