Sets up the complete application skeleton for Flipventory v2, an inventory management tool for small/medium resellers using chaotic stockpiling. Architecture ------------ - app/config.py pydantic-settings; reads .env; holds admin_email guard - app/database.py SQLAlchemy 2.x engine, SessionLocal, Base, get_db dep - app/models/user.py User (email, bcrypt hash, is_admin bool, is_active) - app/models/item.py StorageBin + Item with FOUND/LISTED/SOLD/ARCHIVED enum - app/schemas/ Pydantic request/response shapes (separate from models) - app/auth/security.py bcrypt password hashing + HS256 JWT creation/validation - app/routers/auth.py POST /auth/register, POST /auth/login, GET /auth/me - app/routers/users.py Admin-only CRUD for user accounts - app/routers/items.py /bins and /items CRUD with role-based access control Key design decisions -------------------- - martin@hohenberg.jp is ALWAYS admin: enforced on every login, cannot be demoted, cannot be deactivated. Prevents accidental lockout. - All other registered e-mails are customers (is_admin=False). - Customers can manage their own bins and items; admins see everything. - Item hard-delete is admin-only; customers archive instead (audit safety). - SQLite default for zero-infra local dev; any SQLAlchemy URL works in prod. - Tables are created on startup via create_tables(); swap for Alembic later. Addresses v1 Gitea issues -------------------------- - Issue #1 (storage bins): StorageBin model + /bins CRUD endpoints - Issue #2 (item CRUD): Item model + /items CRUD endpoints - Issue #3 (audit logging): created_by_id / updated_by_id on every item row Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
73 lines
2.5 KiB
Python
73 lines
2.5 KiB
Python
"""
|
||
User model.
|
||
|
||
Roles
|
||
------
|
||
There are exactly two roles:
|
||
- **admin** – full access to everything: create/edit/delete any item or
|
||
user, view all storage bins, access audit logs.
|
||
- **customer** – limited view: can browse listed inventory and manage their
|
||
own storage bins.
|
||
|
||
The role is stored as a simple boolean ``is_admin`` flag. The application
|
||
config defines the one e-mail address that is ALWAYS admin (see
|
||
``settings.admin_email``). On every login that address will have its flag
|
||
forced to True, so even if the database is tampered with, the admin cannot be
|
||
locked out.
|
||
"""
|
||
from datetime import datetime
|
||
|
||
from sqlalchemy import Boolean, DateTime, String, func
|
||
from sqlalchemy.orm import Mapped, mapped_column
|
||
|
||
from app.database import Base
|
||
|
||
|
||
class User(Base):
|
||
__tablename__ = "users"
|
||
|
||
# ------------------------------------------------------------------
|
||
# Columns
|
||
# ------------------------------------------------------------------
|
||
|
||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||
|
||
# E-mail is the login identifier. Must be unique across the system.
|
||
email: Mapped[str] = mapped_column(
|
||
String(254), # RFC 5321 max e-mail length
|
||
unique=True,
|
||
index=True,
|
||
nullable=False,
|
||
)
|
||
|
||
# Display name shown in the UI. Defaults to the e-mail local part if
|
||
# not set during registration.
|
||
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||
|
||
# bcrypt hash of the password. We never store plaintext.
|
||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||
|
||
# True = admin, False = customer. The business rule that
|
||
# settings.admin_email is always admin is enforced in the auth layer,
|
||
# not here.
|
||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||
|
||
# Soft-disable an account without deleting it. A disabled account cannot
|
||
# log in.
|
||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||
|
||
# Audit timestamps set automatically by the database.
|
||
created_at: Mapped[datetime] = mapped_column(
|
||
DateTime, server_default=func.now(), nullable=False
|
||
)
|
||
updated_at: Mapped[datetime] = mapped_column(
|
||
DateTime,
|
||
server_default=func.now(),
|
||
onupdate=func.now(),
|
||
nullable=False,
|
||
)
|
||
|
||
def __repr__(self) -> str:
|
||
role = "admin" if self.is_admin else "customer"
|
||
return f"<User id={self.id} email={self.email!r} role={role}>"
|