Igen
This commit is contained in:
@@ -1,11 +1,24 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str
|
||||
SECRET_KEY: str
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 dage
|
||||
|
||||
# Mail
|
||||
MAIL_HOST: str = "mailhog"
|
||||
MAIL_PORT: int = 1025
|
||||
MAIL_FROM: str = "noreply@linedance.local"
|
||||
MAIL_USERNAME: str = ""
|
||||
MAIL_PASSWORD: str = ""
|
||||
MAIL_TLS: bool = False
|
||||
|
||||
# Base URL til verificerings-links
|
||||
BASE_URL: str = "http://localhost:8000"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
103
linedance-api/app/core/mail.py
Normal file
103
linedance-api/app/core/mail.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
mail.py — Asynkron mail-sending via aiosmtplib.
|
||||
I udvikling bruges MailHog som SMTP-server.
|
||||
"""
|
||||
import asyncio
|
||||
import secrets
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import aiosmtplib
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def generate_verify_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
async def send_verification_email(email: str, username: str, token: str):
|
||||
verify_url = f"{settings.BASE_URL}/auth/verify/{token}"
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = "Bekræft din LineDance-konto"
|
||||
msg["From"] = settings.MAIL_FROM
|
||||
msg["To"] = email
|
||||
|
||||
text = f"""Hej {username},
|
||||
|
||||
Tak for at oprette en konto på LineDance Player.
|
||||
|
||||
Klik på linket nedenfor for at bekræfte din e-mailadresse:
|
||||
{verify_url}
|
||||
|
||||
Linket udløber ikke — men kontoen er ikke aktiv før du har bekræftet.
|
||||
|
||||
Hilsen
|
||||
LineDance Player
|
||||
"""
|
||||
|
||||
html = f"""<html><body>
|
||||
<h2>Velkommen til LineDance Player, {username}!</h2>
|
||||
<p>Klik på knappen nedenfor for at bekræfte din e-mailadresse:</p>
|
||||
<p>
|
||||
<a href="{verify_url}"
|
||||
style="background:#e8a020;color:#111;padding:12px 24px;
|
||||
border-radius:6px;text-decoration:none;font-weight:bold;">
|
||||
Bekræft e-mail
|
||||
</a>
|
||||
</p>
|
||||
<p>Eller kopier dette link:<br>
|
||||
<a href="{verify_url}">{verify_url}</a></p>
|
||||
<p>Linket udløber ikke.</p>
|
||||
</body></html>"""
|
||||
|
||||
msg.attach(MIMEText(text, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=settings.MAIL_HOST,
|
||||
port=settings.MAIL_PORT,
|
||||
username=settings.MAIL_USERNAME or None,
|
||||
password=settings.MAIL_PASSWORD or None,
|
||||
use_tls=settings.MAIL_TLS,
|
||||
)
|
||||
except Exception as e:
|
||||
# Log fejl men lad registrering gennemføre
|
||||
print(f"Mail-fejl: {e}")
|
||||
|
||||
|
||||
async def send_share_invitation(email: str, owner_name: str,
|
||||
playlist_name: str, permission: str,
|
||||
accept_url: str):
|
||||
perm_text = {"view": "se", "copy": "kopiere", "edit": "redigere"}.get(permission, "se")
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = f"{owner_name} har delt en danseliste med dig"
|
||||
msg["From"] = settings.MAIL_FROM
|
||||
msg["To"] = email
|
||||
|
||||
html = f"""<html><body>
|
||||
<h2>Du er inviteret!</h2>
|
||||
<p>{owner_name} har delt danselisten <strong>{playlist_name}</strong> med dig.</p>
|
||||
<p>Du har fået adgang til at <strong>{perm_text}</strong> listen.</p>
|
||||
<p>
|
||||
<a href="{accept_url}"
|
||||
style="background:#e8a020;color:#111;padding:12px 24px;
|
||||
border-radius:6px;text-decoration:none;font-weight:bold;">
|
||||
Se danseliste
|
||||
</a>
|
||||
</p>
|
||||
</body></html>"""
|
||||
|
||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=settings.MAIL_HOST,
|
||||
port=settings.MAIL_PORT,
|
||||
use_tls=settings.MAIL_TLS,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Mail-fejl (share): {e}")
|
||||
@@ -1,8 +1,8 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
@@ -10,44 +10,32 @@ from app.core.database import get_db
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
return jwt.encode({**data, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
return jwt.encode({**data, "exp": expire}, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
from app.models.user import User
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Kunne ikke validere token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
from app.models import User
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id: str = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(401, "Ugyldig token")
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
raise HTTPException(401, "Ugyldig token")
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
if not user:
|
||||
raise HTTPException(401, "Bruger ikke fundet")
|
||||
return user
|
||||
|
||||
@@ -17,16 +17,20 @@ def now_utc() -> datetime:
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(128), default="")
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
verify_token: Mapped[str|None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner")
|
||||
memberships: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="user")
|
||||
songs: Mapped[list["Song"]] = relationship("Song", back_populates="owner")
|
||||
projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner")
|
||||
memberships: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="user")
|
||||
songs: Mapped[list["Song"]] = relationship("Song", back_populates="owner")
|
||||
alt_ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="user")
|
||||
playlist_shares: Mapped[list["PlaylistShare"]] = relationship("PlaylistShare", foreign_keys="PlaylistShare.shared_with_id", back_populates="shared_with")
|
||||
|
||||
|
||||
# ── Song ──────────────────────────────────────────────────────────────────────
|
||||
@@ -42,12 +46,12 @@ class Song(Base):
|
||||
bpm: Mapped[int] = mapped_column(Integer, default=0)
|
||||
duration_sec: Mapped[int] = mapped_column(Integer, default=0)
|
||||
file_format: Mapped[str] = mapped_column(String(8), default="")
|
||||
mbid: Mapped[str|None] = mapped_column(String(36), nullable=True) # MusicBrainz ID
|
||||
acoustid: Mapped[str|None] = mapped_column(String(64), nullable=True) # AcoustID fingerprint
|
||||
mbid: Mapped[str|None] = mapped_column(String(36), nullable=True)
|
||||
acoustid: Mapped[str|None] = mapped_column(String(64), nullable=True)
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
owner: Mapped["User"] = relationship("User", back_populates="songs")
|
||||
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="song")
|
||||
owner: Mapped["User"] = relationship("User", back_populates="songs")
|
||||
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="song")
|
||||
|
||||
|
||||
# ── Dans-entitet ──────────────────────────────────────────────────────────────
|
||||
@@ -66,12 +70,16 @@ class Dance(Base):
|
||||
__tablename__ = "dances"
|
||||
__table_args__ = (UniqueConstraint("name", "level_id", name="uq_dance_name_level"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
level_id: Mapped[int|None] = mapped_column(Integer, ForeignKey("dance_levels.id"), nullable=True)
|
||||
use_count: Mapped[int] = mapped_column(Integer, default=1)
|
||||
source: Mapped[str] = mapped_column(String(16), default="local") # local | community
|
||||
synced_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
level_id: Mapped[int|None] = mapped_column(Integer, ForeignKey("dance_levels.id"), nullable=True)
|
||||
choreographer: Mapped[str] = mapped_column(String(128), default="")
|
||||
video_url: Mapped[str] = mapped_column(String(512), default="")
|
||||
stepsheet_url: Mapped[str] = mapped_column(String(512), default="")
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
use_count: Mapped[int] = mapped_column(Integer, default=1)
|
||||
source: Mapped[str] = mapped_column(String(16), default="local")
|
||||
synced_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
level: Mapped["DanceLevel|None"] = relationship("DanceLevel")
|
||||
|
||||
@@ -85,12 +93,14 @@ class Project(Base):
|
||||
owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
visibility: Mapped[str] = mapped_column(String(16), default="private") # private|shared|public
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, onupdate=now_utc)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
owner: Mapped["User"] = relationship("User", back_populates="projects")
|
||||
members: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
|
||||
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="project", order_by="ProjectSong.position", cascade="all, delete-orphan")
|
||||
shares: Mapped[list["PlaylistShare"]] = relationship("PlaylistShare", back_populates="project", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ProjectMember(Base):
|
||||
@@ -99,8 +109,8 @@ class ProjectMember(Base):
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(16), default="viewer") # owner | editor | viewer
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending | accepted
|
||||
role: Mapped[str] = mapped_column(String(16), default="viewer") # owner|editor|viewer
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending|accepted
|
||||
invited_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
project: Mapped["Project"] = relationship("Project", back_populates="members")
|
||||
@@ -110,43 +120,57 @@ class ProjectMember(Base):
|
||||
class ProjectSong(Base):
|
||||
__tablename__ = "project_songs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
|
||||
song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending|played|skipped
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
|
||||
song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
is_workshop: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
dance_override: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
project: Mapped["Project"] = relationship("Project", back_populates="project_songs")
|
||||
song: Mapped["Song"] = relationship("Song", back_populates="project_songs")
|
||||
|
||||
|
||||
class PlaylistShare(Base):
|
||||
"""Deling af en playlist med specifikke brugere."""
|
||||
__tablename__ = "playlist_shares"
|
||||
__table_args__ = (UniqueConstraint("project_id", "shared_with_id", name="uq_share"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
|
||||
shared_with_id: Mapped[str|None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
|
||||
invited_email: Mapped[str] = mapped_column(String(255), default="") # til ikke-registrerede
|
||||
permission: Mapped[str] = mapped_column(String(16), default="view") # view|copy|edit
|
||||
accepted_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
project: Mapped["Project"] = relationship("Project", back_populates="shares")
|
||||
shared_with: Mapped["User|None"] = relationship("User", foreign_keys=[shared_with_id], back_populates="playlist_shares")
|
||||
|
||||
|
||||
# ── Community dans-tags ───────────────────────────────────────────────────────
|
||||
|
||||
class CommunityDance(Base):
|
||||
"""Fællesskabets dans-tags på sange — identificeret ved mbid eller titel+artist."""
|
||||
"""Fællesskabets dans-tags på sange."""
|
||||
__tablename__ = "community_dances"
|
||||
__table_args__ = (UniqueConstraint("song_mbid", "dance_id", name="uq_comm_dance"),)
|
||||
__table_args__ = (UniqueConstraint("song_mbid", "song_title", "song_artist", "dance_id", name="uq_comm_dance"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
song_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True)
|
||||
song_title: Mapped[str] = mapped_column(String(255), default="")
|
||||
song_artist: Mapped[str] = mapped_column(String(255), default="")
|
||||
dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False)
|
||||
submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
song_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True)
|
||||
song_title: Mapped[str] = mapped_column(String(255), default="")
|
||||
song_artist: Mapped[str] = mapped_column(String(255), default="")
|
||||
dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False)
|
||||
submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
dance: Mapped["Dance"] = relationship("Dance")
|
||||
|
||||
|
||||
# ── Community alternativ-dans + rating ────────────────────────────────────────
|
||||
|
||||
class CommunityDanceAlt(Base):
|
||||
"""Fællesskabets alternativ-danse til en sang — uafhængigt af sangens hoveddanse."""
|
||||
"""Fællesskabets alternativ-danse til en sang med ratings."""
|
||||
__tablename__ = "community_dance_alts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("song_mbid", "song_title", "song_artist",
|
||||
"alt_dance_id", name="uq_comm_alt"),
|
||||
)
|
||||
__table_args__ = (UniqueConstraint("song_mbid", "song_title", "song_artist", "alt_dance_id", name="uq_comm_alt"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
song_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True)
|
||||
@@ -159,16 +183,14 @@ class CommunityDanceAlt(Base):
|
||||
rating_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
alt_dance: Mapped["Dance"] = relationship("Dance", foreign_keys=[alt_dance_id])
|
||||
alt_dance: Mapped["Dance"] = relationship("Dance")
|
||||
ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="alternative", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class DanceAltRating(Base):
|
||||
"""En brugers 1-5 stjerne rating — knyttet til sang + dans + alternativ + bruger."""
|
||||
"""1-5 stjerne rating af en alternativ-dans."""
|
||||
__tablename__ = "dance_alt_ratings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("alternative_id", "user_id", name="uq_rating"),
|
||||
)
|
||||
__table_args__ = (UniqueConstraint("alternative_id", "user_id", name="uq_rating"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
alternative_id: Mapped[str] = mapped_column(String(36), ForeignKey("community_dance_alts.id"), nullable=False)
|
||||
|
||||
@@ -1,39 +1,151 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
"""
|
||||
auth.py — Register, verify, login, profil.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import hash_password, verify_password, create_access_token
|
||||
from app.core.security import hash_password, verify_password, create_access_token, get_current_user
|
||||
from app.core.mail import generate_verify_token, send_verification_email
|
||||
from app.models import User
|
||||
from app.schemas import UserCreate, UserOut, Token
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut, status_code=201)
|
||||
def register(data: UserCreate, db: Session = Depends(get_db)):
|
||||
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
full_name: str = ""
|
||||
password: str
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
email: str
|
||||
full_name: str
|
||||
is_verified: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserOut
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/register", response_model=dict, status_code=201)
|
||||
async def register(
|
||||
data: UserCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Tjek om brugernavn eller email allerede er i brug
|
||||
if db.query(User).filter(User.username == data.username).first():
|
||||
raise HTTPException(400, "Brugernavnet er allerede i brug")
|
||||
if db.query(User).filter(User.email == data.email).first():
|
||||
raise HTTPException(400, "E-mailen er allerede i brug")
|
||||
raise HTTPException(400, "E-mailadressen er allerede registreret")
|
||||
|
||||
token = generate_verify_token()
|
||||
user = User(
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
full_name=data.full_name,
|
||||
password_hash=hash_password(data.password),
|
||||
is_verified=False,
|
||||
verify_token=token,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
# Send verificerings-mail i baggrunden
|
||||
background_tasks.add_task(
|
||||
send_verification_email, data.email, data.username, token
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Konto oprettet. Tjek din e-mail ({data.email}) for at bekræfte.",
|
||||
"email": data.email,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/verify/{token}", response_class=HTMLResponse)
|
||||
def verify_email(token: str, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.verify_token == token).first()
|
||||
if not user:
|
||||
return HTMLResponse("""
|
||||
<html><body style="font-family:sans-serif;text-align:center;padding:60px">
|
||||
<h2>❌ Ugyldigt eller udløbet link</h2>
|
||||
<p>Prøv at registrere dig igen.</p>
|
||||
</body></html>
|
||||
""", status_code=400)
|
||||
|
||||
user.is_verified = True
|
||||
user.verify_token = None
|
||||
db.commit()
|
||||
|
||||
return HTMLResponse("""
|
||||
<html><body style="font-family:sans-serif;text-align:center;padding:60px;
|
||||
background:#1a1d23;color:#e0e4f0">
|
||||
<h2 style="color:#e8a020">✓ E-mail bekræftet!</h2>
|
||||
<p>Din konto er nu aktiv. Du kan logge ind i LineDance Player.</p>
|
||||
<p style="color:#5a6070;font-size:14px">Du kan lukke dette vindue.</p>
|
||||
</body></html>
|
||||
""")
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == form.username).first()
|
||||
def login(
|
||||
form: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
user = db.query(User).filter(
|
||||
(User.username == form.username) | (User.email == form.username)
|
||||
).first()
|
||||
|
||||
if not user or not verify_password(form.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Forkert brugernavn eller kodeord",
|
||||
)
|
||||
raise HTTPException(401, "Forkert brugernavn eller kodeord")
|
||||
|
||||
if not user.is_verified:
|
||||
raise HTTPException(403, "E-mailadressen er ikke bekræftet endnu. Tjek din indbakke.")
|
||||
|
||||
token = create_access_token({"sub": user.id})
|
||||
return {"access_token": token}
|
||||
return Token(
|
||||
access_token=token,
|
||||
user=UserOut.model_validate(user)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/resend-verification")
|
||||
async def resend_verification(
|
||||
email: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user or user.is_verified:
|
||||
# Svar altid OK — afslør ikke om email eksisterer
|
||||
return {"message": "Hvis e-mailen er registreret, sendes et nyt link."}
|
||||
|
||||
token = generate_verify_token()
|
||||
user.verify_token = token
|
||||
db.commit()
|
||||
|
||||
background_tasks.add_task(
|
||||
send_verification_email, user.email, user.username, token
|
||||
)
|
||||
return {"message": "Nyt verificerings-link er sendt."}
|
||||
|
||||
Reference in New Issue
Block a user