diff --git a/API_ENDPOINTS.md b/API_ENDPOINTS.md new file mode 100644 index 0000000..1d6e2e1 --- /dev/null +++ b/API_ENDPOINTS.md @@ -0,0 +1,75 @@ +# API-Endpunkte + +Diese Endpunkte decken die Kernfunktionen von OnlyPrompt ab. + +## Auth + +### `POST /api/v1/auth/register` +- Erstellt einen neuen Benutzer. + +### `POST /api/v1/auth/login` +- Loggt einen Benutzer ein. + +### `GET /api/v1/auth/me` +- Gibt den aktuell eingeloggten Benutzer zurueck. + +## User / Profile + +### `GET /api/v1/users/{id}` +- Ruft das oeffentliche Benutzerprofil anhand der ID ab. + +### `GET /api/v1/profile/me` +- Ruft das eigene Profil des eingeloggten Benutzers ab. + +### `PUT /api/v1/profile/me` +- Bearbeitet das eigene Profil des eingeloggten Benutzers. + +## Prompts + +### `GET /api/v1/prompts` +- Gibt alle veroeffentlichten Prompts zurueck. + +### `GET /api/v1/prompts/{id}` +- Ruft einen einzelnen Prompt anhand der ID ab. + +### `POST /api/v1/prompts` +- Erstellt einen neuen Prompt. + +### `PUT /api/v1/prompts/{id}` +- Bearbeitet einen bestehenden Prompt. + +### `DELETE /api/v1/prompts/{id}` +- Loescht einen Prompt. + +## Filter fuer Marketplace + +### `GET /api/v1/prompts?category=coding` +- Filtert Prompts nach Kategorie. + +### `GET /api/v1/prompts?search=python` +- Sucht Prompts ueber einen Suchbegriff. + +### `GET /api/v1/prompts?creatorId=5` +- Filtert Prompts nach einem bestimmten Creator. + +## Kategorien + +### `GET /api/v1/categories` +- Gibt alle verfuegbaren Kategorien zurueck. + +## Kaeufe + +### `POST /api/v1/purchases` +- Erstellt einen Kauf fuer einen Prompt. + +### `GET /api/v1/purchases/me` +- Gibt alle eigenen Kaeufe des eingeloggten Benutzers zurueck. + +## Reviews + +### `GET /api/v1/prompts/{id}/reviews` +- Ruft alle Bewertungen fuer einen Prompt ab. + +### `POST /api/v1/prompts/{id}/reviews` +- Erstellt eine neue Bewertung fuer einen Prompt. + diff --git a/KERNMODELL.md b/KERNMODELL.md new file mode 100644 index 0000000..7416f8d --- /dev/null +++ b/KERNMODELL.md @@ -0,0 +1,78 @@ +# Kernmodell + +Dieses Kernmodell beschreibt die wichtigsten Klassen, die OnlyPrompt fuer Login, Profile, Marketplace und Prompt-Kaeufe benoetigt. + +## Klassen + +### User +- `id: int` +- `username: string` +- `email: string` +- `passwordHash: string` +- `role: string` +- `createdAt: datetime` + +### Profile +- `id: int` +- `userId: int` +- `displayName: string` +- `bio: string` +- `avatarUrl: string` +- `specialties: string` + +### Prompt +- `id: int` +- `creatorId: int` +- `categoryId: int` +- `title: string` +- `description: string` +- `content: text` +- `price: decimal` +- `thumbnailUrl: string` +- `ratingAverage: float` +- `reviewCount: int` +- `status: string` +- `createdAt: datetime` +- `updatedAt: datetime` + +### Category +- `id: int` +- `name: string` +- `slug: string` + +### Purchase +- `id: int` +- `buyerId: int` +- `promptId: int` +- `pricePaid: decimal` +- `purchasedAt: datetime` + +### Review +- `id: int` +- `promptId: int` +- `userId: int` +- `rating: int` +- `comment: string` +- `createdAt: datetime` + +## Beziehungen + +- Ein `User` hat genau ein `Profile`. +- Ein `User` kann viele `Prompts` erstellen. +- Ein `Prompt` gehoert zu genau einer `Category`. +- Ein `User` kann viele `Prompts` ueber `Purchase` kaufen. +- Ein `User` kann viele `Reviews` schreiben. +- Ein `Prompt` kann viele `Reviews` erhalten. + +## UML-Kurzform + +```text +User 1 --- 1 Profile +User 1 --- * Prompt +Category 1 --- * Prompt +User 1 --- * Purchase +Prompt 1 --- * Purchase +User 1 --- * Review +Prompt 1 --- * Review +``` + diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/__pycache__/__init__.cpython-313.pyc b/backend/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..ccdb4d3 Binary files /dev/null and b/backend/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..d55f604 Binary files /dev/null and b/backend/__pycache__/main.cpython-313.pyc differ diff --git a/backend/__pycache__/models.cpython-313.pyc b/backend/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..5e1e64a Binary files /dev/null and b/backend/__pycache__/models.cpython-313.pyc differ diff --git a/backend/__pycache__/schemas.cpython-313.pyc b/backend/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000..da53d6f Binary files /dev/null and b/backend/__pycache__/schemas.cpython-313.pyc differ diff --git a/backend/__pycache__/store.cpython-313.pyc b/backend/__pycache__/store.cpython-313.pyc new file mode 100644 index 0000000..432ccdf Binary files /dev/null and b/backend/__pycache__/store.cpython-313.pyc differ diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..10d92d6 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,346 @@ +from datetime import datetime + +from fastapi import FastAPI, HTTPException, Query, Response, status + +from .models import ChatMessage, Favorite, Follow, Prompt, Rating, User +from .schemas import ( + AuthResponse, + ChatMessageCreateRequest, + ChatMessageResponse, + CreatorDetailResponse, + FavoriteCreateRequest, + FavoriteResponse, + FollowResponse, + LoginRequest, + ProfileUpdateRequest, + PromptCreateRequest, + PromptResponse, + PromptUpdateRequest, + RatingCreateRequest, + RatingResponse, + RegisterRequest, + UserResponse, +) +from .store import store + +app = FastAPI(title="OnlyPrompt API", version="1.0.0") + + +def get_current_user() -> User: + user = store.users.get(store.current_user_id) + if user is None: + raise HTTPException(status_code=404, detail="Current user not found.") + return user + + +@app.post("/api/auth/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED) +def register(payload: RegisterRequest) -> AuthResponse: + if any(user.email == payload.email for user in store.users.values()): + raise HTTPException(status_code=400, detail="Email already exists.") + if any(user.username == payload.username for user in store.users.values()): + raise HTTPException(status_code=400, detail="Username already exists.") + + now = datetime.utcnow() + user_id = store.next_id("user") + user = User( + id=user_id, + email=payload.email, + password_hash=payload.password, + full_name=payload.full_name, + username=payload.username, + bio="", + location="", + avatar_url="", + role=payload.role, + is_verified=False, + created_at=now, + ) + store.users[user_id] = user + store.current_user_id = user_id + return AuthResponse(message="User created successfully.", user=UserResponse.model_validate(user)) + + +@app.post("/api/auth/login", response_model=AuthResponse) +def login(payload: LoginRequest) -> AuthResponse: + user = next((item for item in store.users.values() if item.email == payload.email), None) + if user is None or user.password_hash != payload.password: + raise HTTPException(status_code=401, detail="Invalid email or password.") + + store.current_user_id = user.id + return AuthResponse(message="Login successful.", user=UserResponse.model_validate(user)) + + +@app.get("/api/prompts", response_model=list[PromptResponse]) +def list_prompts( + category: str | None = Query(default=None), + search: str | None = Query(default=None), +) -> list[PromptResponse]: + prompts = list(store.prompts.values()) + + if category: + prompts = [prompt for prompt in prompts if prompt.category.lower() == category.lower()] + + if search: + term = search.lower() + prompts = [ + prompt + for prompt in prompts + if term in prompt.title.lower() or term in prompt.description.lower() + ] + + return [PromptResponse.model_validate(prompt) for prompt in prompts] + + +@app.get("/api/prompts/{prompt_id}", response_model=PromptResponse) +def get_prompt(prompt_id: int) -> PromptResponse: + prompt = store.prompts.get(prompt_id) + if prompt is None: + raise HTTPException(status_code=404, detail="Prompt not found.") + return PromptResponse.model_validate(prompt) + + +@app.post("/api/prompts", response_model=PromptResponse, status_code=status.HTTP_201_CREATED) +def create_prompt(payload: PromptCreateRequest) -> PromptResponse: + creator = store.users.get(payload.creator_id) + if creator is None or creator.role != "creator": + raise HTTPException(status_code=404, detail="Creator not found.") + + prompt = Prompt( + id=store.next_id("prompt"), + title=payload.title, + description=payload.description, + content=payload.content, + image_url=payload.image_url, + category=payload.category, + price=payload.price, + creator_id=payload.creator_id, + created_at=datetime.utcnow(), + ) + store.prompts[prompt.id] = prompt + return PromptResponse.model_validate(prompt) + + +@app.put("/api/prompts/{prompt_id}", response_model=PromptResponse) +def update_prompt(prompt_id: int, payload: PromptUpdateRequest) -> PromptResponse: + prompt = store.prompts.get(prompt_id) + if prompt is None: + raise HTTPException(status_code=404, detail="Prompt not found.") + + updates = payload.model_dump(exclude_unset=True) + for field, value in updates.items(): + setattr(prompt, field, value) + return PromptResponse.model_validate(prompt) + + +@app.delete("/api/prompts/{prompt_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_prompt(prompt_id: int) -> Response: + if prompt_id not in store.prompts: + raise HTTPException(status_code=404, detail="Prompt not found.") + del store.prompts[prompt_id] + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@app.get("/api/creators", response_model=list[UserResponse]) +def list_creators(sort: str | None = Query(default=None)) -> list[UserResponse]: + creators = [user for user in store.users.values() if user.role == "creator"] + + if sort == "new": + creators.sort(key=lambda item: item.created_at, reverse=True) + elif sort == "popular": + creators.sort( + key=lambda item: sum(1 for follow in store.follows.values() if follow.creator_id == item.id), + reverse=True, + ) + elif sort == "top_rated": + def creator_rating(user: User) -> float: + creator_prompt_ids = [prompt.id for prompt in store.prompts.values() if prompt.creator_id == user.id] + ratings = [rating.score for rating in store.ratings.values() if rating.prompt_id in creator_prompt_ids] + return sum(ratings) / len(ratings) if ratings else 0 + + creators.sort(key=creator_rating, reverse=True) + + return [UserResponse.model_validate(creator) for creator in creators] + + +@app.get("/api/creators/{creator_id}", response_model=CreatorDetailResponse) +def get_creator(creator_id: int) -> CreatorDetailResponse: + creator = store.users.get(creator_id) + if creator is None or creator.role != "creator": + raise HTTPException(status_code=404, detail="Creator not found.") + + prompts = [prompt for prompt in store.prompts.values() if prompt.creator_id == creator_id] + return CreatorDetailResponse( + creator=UserResponse.model_validate(creator), + prompts=[PromptResponse.model_validate(prompt) for prompt in prompts], + ) + + +@app.get("/api/prompts/{prompt_id}/ratings", response_model=list[RatingResponse]) +def list_ratings(prompt_id: int) -> list[RatingResponse]: + if prompt_id not in store.prompts: + raise HTTPException(status_code=404, detail="Prompt not found.") + ratings = [rating for rating in store.ratings.values() if rating.prompt_id == prompt_id] + return [RatingResponse.model_validate(rating) for rating in ratings] + + +@app.post("/api/prompts/{prompt_id}/ratings", response_model=RatingResponse, status_code=status.HTTP_201_CREATED) +def create_rating(prompt_id: int, payload: RatingCreateRequest) -> RatingResponse: + if prompt_id not in store.prompts: + raise HTTPException(status_code=404, detail="Prompt not found.") + if payload.user_id not in store.users: + raise HTTPException(status_code=404, detail="User not found.") + + rating = Rating( + id=store.next_id("rating"), + prompt_id=prompt_id, + user_id=payload.user_id, + score=payload.score, + comment=payload.comment, + created_at=datetime.utcnow(), + ) + store.ratings[rating.id] = rating + return RatingResponse.model_validate(rating) + + +@app.get("/api/favorites", response_model=list[FavoriteResponse]) +def list_favorites() -> list[FavoriteResponse]: + current_user = get_current_user() + favorites = [item for item in store.favorites.values() if item.user_id == current_user.id] + return [FavoriteResponse.model_validate(item) for item in favorites] + + +@app.post("/api/favorites", response_model=FavoriteResponse, status_code=status.HTTP_201_CREATED) +def create_favorite(payload: FavoriteCreateRequest) -> FavoriteResponse: + current_user = get_current_user() + if payload.prompt_id not in store.prompts: + raise HTTPException(status_code=404, detail="Prompt not found.") + + existing = next( + ( + item + for item in store.favorites.values() + if item.user_id == current_user.id and item.prompt_id == payload.prompt_id + ), + None, + ) + if existing is not None: + return FavoriteResponse.model_validate(existing) + + favorite = Favorite( + id=store.next_id("favorite"), + user_id=current_user.id, + prompt_id=payload.prompt_id, + created_at=datetime.utcnow(), + ) + store.favorites[favorite.id] = favorite + return FavoriteResponse.model_validate(favorite) + + +@app.delete("/api/favorites/{prompt_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_favorite(prompt_id: int) -> Response: + current_user = get_current_user() + favorite_id = next( + ( + item.id + for item in store.favorites.values() + if item.user_id == current_user.id and item.prompt_id == prompt_id + ), + None, + ) + if favorite_id is None: + raise HTTPException(status_code=404, detail="Favorite not found.") + + del store.favorites[favorite_id] + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@app.post("/api/follows/{creator_id}", response_model=FollowResponse, status_code=status.HTTP_201_CREATED) +def create_follow(creator_id: int) -> FollowResponse: + current_user = get_current_user() + creator = store.users.get(creator_id) + if creator is None or creator.role != "creator": + raise HTTPException(status_code=404, detail="Creator not found.") + + existing = next( + ( + item + for item in store.follows.values() + if item.follower_id == current_user.id and item.creator_id == creator_id + ), + None, + ) + if existing is not None: + return FollowResponse.model_validate(existing) + + follow = Follow( + id=store.next_id("follow"), + follower_id=current_user.id, + creator_id=creator_id, + created_at=datetime.utcnow(), + ) + store.follows[follow.id] = follow + return FollowResponse.model_validate(follow) + + +@app.delete("/api/follows/{creator_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_follow(creator_id: int) -> Response: + current_user = get_current_user() + follow_id = next( + ( + item.id + for item in store.follows.values() + if item.follower_id == current_user.id and item.creator_id == creator_id + ), + None, + ) + if follow_id is None: + raise HTTPException(status_code=404, detail="Follow not found.") + + del store.follows[follow_id] + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@app.get("/api/profile", response_model=UserResponse) +def get_profile() -> UserResponse: + return UserResponse.model_validate(get_current_user()) + + +@app.put("/api/profile", response_model=UserResponse) +def update_profile(payload: ProfileUpdateRequest) -> UserResponse: + user = get_current_user() + updates = payload.model_dump(exclude_unset=True) + for field, value in updates.items(): + setattr(user, field, value) + return UserResponse.model_validate(user) + + +@app.get("/api/chats/{user_id}", response_model=list[ChatMessageResponse]) +def get_chat(user_id: int) -> list[ChatMessageResponse]: + current_user = get_current_user() + if user_id not in store.users: + raise HTTPException(status_code=404, detail="User not found.") + + messages = [ + message + for message in store.chat_messages.values() + if {message.sender_id, message.receiver_id} == {current_user.id, user_id} + ] + messages.sort(key=lambda item: item.created_at) + return [ChatMessageResponse.model_validate(message) for message in messages] + + +@app.post("/api/chats", response_model=ChatMessageResponse, status_code=status.HTTP_201_CREATED) +def create_chat_message(payload: ChatMessageCreateRequest) -> ChatMessageResponse: + current_user = get_current_user() + if payload.receiver_id not in store.users: + raise HTTPException(status_code=404, detail="Receiver not found.") + + message = ChatMessage( + id=store.next_id("chat_message"), + sender_id=current_user.id, + receiver_id=payload.receiver_id, + content=payload.content, + created_at=datetime.utcnow(), + ) + store.chat_messages[message.id] = message + return ChatMessageResponse.model_validate(message) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..d3ca0e3 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class User: + id: int + email: str + password_hash: str + full_name: str + username: str + bio: str + location: str + avatar_url: str + role: str + is_verified: bool + created_at: datetime + + +@dataclass +class Prompt: + id: int + title: str + description: str + content: str + image_url: str + category: str + price: float + creator_id: int + created_at: datetime + + +@dataclass +class Rating: + id: int + prompt_id: int + user_id: int + score: int + comment: str + created_at: datetime + + +@dataclass +class Favorite: + id: int + user_id: int + prompt_id: int + created_at: datetime + + +@dataclass +class Follow: + id: int + follower_id: int + creator_id: int + created_at: datetime + + +@dataclass +class ChatMessage: + id: int + sender_id: int + receiver_id: int + content: str + created_at: datetime diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..53ee599 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,137 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +class UserResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + email: EmailStr + full_name: str + username: str + bio: str + location: str + avatar_url: str + role: str + is_verified: bool + created_at: datetime + + +class RegisterRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=6) + full_name: str = Field(min_length=2, max_length=100) + username: str = Field(min_length=3, max_length=50) + role: str = Field(default="user") + + +class LoginRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=6) + + +class AuthResponse(BaseModel): + message: str + user: UserResponse + + +class ProfileUpdateRequest(BaseModel): + full_name: Optional[str] = Field(default=None, min_length=2, max_length=100) + bio: Optional[str] = Field(default=None, max_length=500) + location: Optional[str] = Field(default=None, max_length=120) + avatar_url: Optional[str] = Field(default=None, max_length=255) + is_verified: Optional[bool] = None + + +class PromptCreateRequest(BaseModel): + title: str = Field(min_length=3, max_length=120) + description: str = Field(min_length=10, max_length=500) + content: str = Field(min_length=10) + image_url: str = Field(max_length=255) + category: str = Field(min_length=2, max_length=50) + price: float = Field(ge=0) + creator_id: int + + +class PromptUpdateRequest(BaseModel): + title: Optional[str] = Field(default=None, min_length=3, max_length=120) + description: Optional[str] = Field(default=None, min_length=10, max_length=500) + content: Optional[str] = Field(default=None, min_length=10) + image_url: Optional[str] = Field(default=None, max_length=255) + category: Optional[str] = Field(default=None, min_length=2, max_length=50) + price: Optional[float] = Field(default=None, ge=0) + + +class PromptResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + title: str + description: str + content: str + image_url: str + category: str + price: float + creator_id: int + created_at: datetime + + +class CreatorDetailResponse(BaseModel): + creator: UserResponse + prompts: list[PromptResponse] + + +class RatingCreateRequest(BaseModel): + user_id: int + score: int = Field(ge=1, le=5) + comment: str = Field(default="", max_length=500) + + +class RatingResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + prompt_id: int + user_id: int + score: int + comment: str + created_at: datetime + + +class FavoriteCreateRequest(BaseModel): + prompt_id: int + + +class FavoriteResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + prompt_id: int + created_at: datetime + + +class FollowResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + follower_id: int + creator_id: int + created_at: datetime + + +class ChatMessageCreateRequest(BaseModel): + receiver_id: int + content: str = Field(min_length=1, max_length=2000) + + +class ChatMessageResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + sender_id: int + receiver_id: int + content: str + created_at: datetime diff --git a/backend/store.py b/backend/store.py new file mode 100644 index 0000000..fe5764e --- /dev/null +++ b/backend/store.py @@ -0,0 +1,103 @@ +from datetime import datetime + +from .models import ChatMessage, Favorite, Follow, Prompt, Rating, User + + +class InMemoryStore: + def __init__(self) -> None: + now = datetime.utcnow() + + self.users = { + 1: User( + id=1, + email="jane@example.com", + password_hash="demo-password", + full_name="Jane Doe", + username="janedoe", + bio="AI creator focused on writing and coding prompts.", + location="Zurich", + avatar_url="/images/content/creator1.png", + role="creator", + is_verified=True, + created_at=now, + ), + 2: User( + id=2, + email="max@example.com", + password_hash="demo-password", + full_name="Max Muster", + username="maxm", + bio="Prompt buyer and tester.", + location="Chur", + avatar_url="/images/content/creator2.png", + role="user", + is_verified=False, + created_at=now, + ), + } + self.prompts = { + 1: Prompt( + id=1, + title="Python Code Assistant", + description="Efficiently debug and write Python code with AI assistance.", + content="You are an expert Python assistant...", + image_url="/images/content/prompt2.png", + category="Coding", + price=19.99, + creator_id=1, + created_at=now, + ) + } + self.ratings = { + 1: Rating( + id=1, + prompt_id=1, + user_id=2, + score=5, + comment="Very useful starter prompt.", + created_at=now, + ) + } + self.favorites = { + 1: Favorite( + id=1, + user_id=2, + prompt_id=1, + created_at=now, + ) + } + self.follows = { + 1: Follow( + id=1, + follower_id=2, + creator_id=1, + created_at=now, + ) + } + self.chat_messages = { + 1: ChatMessage( + id=1, + sender_id=2, + receiver_id=1, + content="Hi, I have a question about your prompt.", + created_at=now, + ) + } + + self.current_user_id = 2 + self.next_ids = { + "user": 3, + "prompt": 2, + "rating": 2, + "favorite": 2, + "follow": 2, + "chat_message": 2, + } + + def next_id(self, key: str) -> int: + value = self.next_ids[key] + self.next_ids[key] += 1 + return value + + +store = InMemoryStore() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..59b441a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn +pydantic +email-validator