Files
flipventory_v2/app/models/user.py
Martin Hohenberg 4e0d0d3962 feat: initial FastAPI stub with auth, user management, and inventory CRUD
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>
2026-06-16 10:40:53 +02:00

73 lines
2.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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}>"