From ae6414c18bb619a0f82d14540aeb57a72991284b Mon Sep 17 00:00:00 2001 From: ciomek Date: Sat, 4 Oct 2025 19:26:11 +0200 Subject: [PATCH] Stuff --- .idea/vcs.xml | 6 + database.sqlite3 | Bin 24576 -> 24576 bytes src/__init__.py | 3 + .../{2607b8f9586f_.py => 24a18ed1c824_.py} | 10 +- src/maps/router.py | 31 ++++- src/maps/schemas.py | 8 ++ src/maps/tests.py | 85 ------------ src/resources/__init__.py | 0 src/resources/models.py | 21 +++ src/resources/routes.py | 76 +++++++++++ src/resources/schemas.py | 19 +++ src/tests/__init__.py | 0 src/tests/conftest.py | 42 ++++++ src/tests/test_maps.py | 75 +++++++++++ src/tests/test_resources.py | 126 ++++++++++++++++++ src/utils/jwt.py | 2 +- 16 files changed, 411 insertions(+), 93 deletions(-) create mode 100644 .idea/vcs.xml rename src/alembic/versions/{2607b8f9586f_.py => 24a18ed1c824_.py} (88%) delete mode 100644 src/maps/tests.py create mode 100644 src/resources/__init__.py create mode 100644 src/resources/models.py create mode 100644 src/resources/routes.py create mode 100644 src/resources/schemas.py create mode 100644 src/tests/__init__.py create mode 100644 src/tests/conftest.py create mode 100644 src/tests/test_maps.py create mode 100644 src/tests/test_resources.py diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/database.sqlite3 b/database.sqlite3 index 3ba17fe3321b71645a2840565fda32b54844e6a6..973b6a25e4cc646c4028009ca7bac09d032f9a53 100644 GIT binary patch delta 73 zcmZoTz}Rqrae_1>>qHr6M%Il9S-g`U@;Xjl$0t7dH=odE6TX9j+$M>J7O5$Q$reT? Tn-sqCqwoz_6gIOM{NV=xa5xtw delta 119 zcmZoTz}Rqrae_1>=R_H2M$U~1S-g|i@fl2h%&Rf^H=odE6TX9j+-3&mNfv3ArWR&t yn-sqCqwoz_6nI$pA29I$;Qzq?g8#v0L4gbW3T!N_4E&s&g>1}Fnh8oXLTCVHJsw2> diff --git a/src/__init__.py b/src/__init__.py index 1bdd097..c7bab60 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,5 +1,7 @@ from fastapi import FastAPI +from src.resources.routes import resources_router + app = FastAPI() from src.users.router import users_router @@ -7,3 +9,4 @@ from src.maps.router import maps_router app.include_router(users_router, prefix="/users") app.include_router(maps_router, prefix="/maps") +app.include_router(resources_router, prefix="/resources") diff --git a/src/alembic/versions/2607b8f9586f_.py b/src/alembic/versions/24a18ed1c824_.py similarity index 88% rename from src/alembic/versions/2607b8f9586f_.py rename to src/alembic/versions/24a18ed1c824_.py index a02657a..f833802 100644 --- a/src/alembic/versions/2607b8f9586f_.py +++ b/src/alembic/versions/24a18ed1c824_.py @@ -1,19 +1,19 @@ """empty message -Revision ID: 2607b8f9586f +Revision ID: 24a18ed1c824 Revises: -Create Date: 2025-10-04 15:15:16.698698 +Create Date: 2025-10-04 18:51:07.316957 """ from typing import Sequence, Union -import sqlmodel from alembic import op import sqlalchemy as sa +import sqlmodel # revision identifiers, used by Alembic. -revision: str = '2607b8f9586f' +revision: str = '24a18ed1c824' down_revision: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -30,10 +30,10 @@ def upgrade() -> None: ) op.create_index(op.f('ix_user_name'), 'user', ['name'], unique=True) op.create_table('waypoint', - sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('x', sa.Float(), nullable=False), sa.Column('y', sa.Float(), nullable=False), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### diff --git a/src/maps/router.py b/src/maps/router.py index f113ff1..a661e69 100644 --- a/src/maps/router.py +++ b/src/maps/router.py @@ -1,16 +1,16 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from typing import List - from sqlmodel import select from src.db import get_db from src.maps.models import Waypoint -from src.maps.schemas import WaypointCreate, WaypointResponse +from src.maps.schemas import WaypointCreate, WaypointResponse, WaypointPatch from src.utils.decorators import auth_required maps_router = APIRouter() + @auth_required() @maps_router.post("/waypoints", response_model=WaypointResponse) def create_waypoint(waypoint: WaypointCreate, db: Session = Depends(get_db)): @@ -35,3 +35,30 @@ def get_waypoint(waypoint_id: int, db: Session = Depends(get_db)): if not wp: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waypoint not found") return wp + + +@auth_required() +@maps_router.patch("/waypoints/{waypoint_id}", response_model=WaypointResponse) +def update_waypoint(waypoint_id: int, waypoint_data: WaypointPatch, db: Session = Depends(get_db)): + wp = db.get(Waypoint, waypoint_id) + if not wp: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waypoint not found") + + for key, value in waypoint_data.model_dump(exclude_unset=True).items(): + setattr(wp, key, value) + + db.add(wp) + db.commit() + db.refresh(wp) + return wp + + +@auth_required() +@maps_router.delete("/waypoints/{waypoint_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_waypoint(waypoint_id: int, db: Session = Depends(get_db)): + wp = db.get(Waypoint, waypoint_id) + if not wp: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waypoint not found") + db.delete(wp) + db.commit() + return diff --git a/src/maps/schemas.py b/src/maps/schemas.py index 38cd898..66b1724 100644 --- a/src/maps/schemas.py +++ b/src/maps/schemas.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic import BaseModel, ConfigDict @@ -6,6 +8,12 @@ class WaypointCreate(BaseModel): x: float y: float + +class WaypointPatch(BaseModel): + name: Optional[str] = None + x: Optional[float] = None + y: Optional[float] = None + class WaypointResponse(WaypointCreate): id: int diff --git a/src/maps/tests.py b/src/maps/tests.py deleted file mode 100644 index e371f54..0000000 --- a/src/maps/tests.py +++ /dev/null @@ -1,85 +0,0 @@ -import pytest -from fastapi.testclient import TestClient -from sqlalchemy import StaticPool -from sqlmodel import SQLModel, create_engine, Session -from sqlalchemy.orm import sessionmaker - -from src import app, maps_router -from src.maps.models import Waypoint -from src.db import get_db - -# --- Setup in-memory SQLite --- -TEST_DATABASE_URL = "sqlite:///:memory:" -engine = create_engine( - TEST_DATABASE_URL, - connect_args={"check_same_thread": False}, - poolclass=StaticPool # <-- crucial for in-memory DB -) -TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) - -# --- Override dependency --- -def override_get_db(): - db = TestingSessionLocal() - try: - yield db - finally: - db.close() - -app.dependency_overrides[get_db] = override_get_db - -# Include router (no need to check prefix) -app.include_router(maps_router, prefix="/maps") - -client = TestClient(app) - -# --- Fixture to create tables --- -@pytest.fixture(scope="module", autouse=True) -def setup_db(): - SQLModel.metadata.create_all(engine) - yield - SQLModel.metadata.drop_all(engine) - -# --- Bypass auth decorator for tests --- -def fake_auth_dependency(): - return lambda: True - -# Monkeypatch your auth_required to do nothing in tests -from src.utils.decorators import auth_required -auth_required = lambda *args, **kwargs: (lambda x: x) - -# --- Tests --- -def test_create_waypoint(): - payload = {"name": "TestPoint", "x": 10.5, "y": 20.5} - response = client.post("/maps/waypoints", json=payload) - assert response.status_code == 200 - data = response.json() - assert data["name"] == payload["name"] - assert data["x"] == payload["x"] - assert data["y"] == payload["y"] - assert "id" in data - -def test_get_waypoints(): - response = client.get("/maps/waypoints") - assert response.status_code == 200 - data = response.json() - assert isinstance(data, list) - assert len(data) >= 1 - -def test_get_waypoint_by_id(): - # Create waypoint first - payload = {"name": "Point1", "x": 1.0, "y": 2.0} - create_resp = client.post("/maps/waypoints", json=payload) - wp_id = create_resp.json()["id"] - - # Fetch by ID - response = client.get(f"/maps/waypoints/{wp_id}") - assert response.status_code == 200 - data = response.json() - assert data["id"] == wp_id - assert data["name"] == payload["name"] - -def test_get_waypoint_not_found(): - response = client.get("/maps/waypoints/9999") - assert response.status_code == 404 - data = response.json() - assert data["detail"] == "Waypoint not found" diff --git a/src/resources/__init__.py b/src/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/resources/models.py b/src/resources/models.py new file mode 100644 index 0000000..3f5556e --- /dev/null +++ b/src/resources/models.py @@ -0,0 +1,21 @@ +from sqlmodel import SQLModel, Field, Relationship +from datetime import datetime +from typing import Optional, List + +class ResourceType(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + changes: List["ResourceChange"] = Relationship(back_populates="type") + + @property + def balance(self) -> float: + return sum(change.change for change in self.changes) + + +class ResourceChange(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + change: float + timestamp: datetime = Field(default_factory=datetime.now) + + type_id: Optional[int] = Field(default=None, foreign_key="resourcetype.id") + type: Optional[ResourceType] = Relationship(back_populates="changes") diff --git a/src/resources/routes.py b/src/resources/routes.py new file mode 100644 index 0000000..e544087 --- /dev/null +++ b/src/resources/routes.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func +from sqlalchemy.orm import Session +from typing import List +from sqlmodel import select + +from src.db import get_db +from src.utils.decorators import auth_required +from src.resources.models import ResourceType, ResourceChange +from src.resources.schemas import ( + ResourceTypeCreate, ResourceTypeResponse, + ResourceChangeCreate, ResourceChangeResponse +) + +resources_router = APIRouter() + +@auth_required() +@resources_router.post("/types", response_model=ResourceTypeResponse) +def create_resource_type(rt: ResourceTypeCreate, db: Session = Depends(get_db)): + db_rt = ResourceType.model_validate(rt) + db.add(db_rt) + db.commit() + db.refresh(db_rt) + return db_rt + + +@auth_required() +@resources_router.get("/types", response_model=List[ResourceTypeResponse]) +def list_resource_types(db: Session = Depends(get_db)): + rts = db.execute(select(ResourceType)).scalars().all() + return rts + + +@auth_required() +@resources_router.get("/types/{type_id}", response_model=ResourceTypeResponse) +def get_resource_type(type_id: int, db: Session = Depends(get_db)): + rt = db.get(ResourceType, type_id) + if not rt: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource type not found") + + return rt + + +@auth_required() +@resources_router.delete("/types/{type_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_resource_type(type_id: int, db: Session = Depends(get_db)): + rt = db.get(ResourceType, type_id) + if not rt: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource type not found") + db.delete(rt) + db.commit() + return + + +@auth_required() +@resources_router.post("/types/{type_id}/changes", response_model=ResourceChangeResponse) +def create_resource_change(type_id: int, change: ResourceChangeCreate, db: Session = Depends(get_db)): + rt = db.get(ResourceType, type_id) + if not rt: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource type not found") + + db_change = ResourceChange.model_validate(change) + db_change.type = rt + db.add(db_change) + db.commit() + db.refresh(db_change) + return db_change + + +@auth_required() +@resources_router.get("/types/{type_id}/changes", response_model=List[ResourceChangeResponse]) +def list_resource_changes(type_id: int, db: Session = Depends(get_db)): + rt = db.get(ResourceType, type_id) + if not rt: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource type not found") + return rt.changes diff --git a/src/resources/schemas.py b/src/resources/schemas.py new file mode 100644 index 0000000..6ec0704 --- /dev/null +++ b/src/resources/schemas.py @@ -0,0 +1,19 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, ConfigDict + +class ResourceTypeCreate(BaseModel): + name: str + +class ResourceTypeResponse(ResourceTypeCreate): + id: int + balance: float + model_config = ConfigDict(from_attributes=True) + +class ResourceChangeCreate(BaseModel): + change: float + +class ResourceChangeResponse(ResourceChangeCreate): + id: int + timestamp: datetime + model_config = ConfigDict(from_attributes=True) diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/conftest.py b/src/tests/conftest.py new file mode 100644 index 0000000..713f95e --- /dev/null +++ b/src/tests/conftest.py @@ -0,0 +1,42 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlmodel import SQLModel +from sqlalchemy.pool import StaticPool +from src import app +from src.db import get_db + +TEST_DATABASE_URL = "sqlite:///:memory:" + +engine = create_engine( + TEST_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool +) + +TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + +@pytest.fixture(scope="session", autouse=True) +def setup_db(): + SQLModel.metadata.create_all(engine) + yield + SQLModel.metadata.drop_all(engine) + +@pytest.fixture(scope="session") +def db_session(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + +@pytest.fixture(scope="session") +def client(db_session): + def override_get_db(): + try: + yield db_session + finally: + pass + app.dependency_overrides[get_db] = override_get_db + return TestClient(app) diff --git a/src/tests/test_maps.py b/src/tests/test_maps.py new file mode 100644 index 0000000..1c1ec45 --- /dev/null +++ b/src/tests/test_maps.py @@ -0,0 +1,75 @@ +def fake_auth_dependency(): + return lambda: True + +auth_required = lambda *args, **kwargs: (lambda x: x) + +def test_create_waypoint(client): + payload = {"name": "TestPoint", "x": 10.5, "y": 20.5} + response = client.post("/maps/waypoints", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["name"] == payload["name"] + assert data["x"] == payload["x"] + assert data["y"] == payload["y"] + assert "id" in data + +def test_get_waypoints(client): + response = client.get("/maps/waypoints") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + +def test_get_waypoint_by_id(client): + payload = {"name": "Point1", "x": 1.0, "y": 2.0} + create_resp = client.post("/maps/waypoints", json=payload) + wp_id = create_resp.json()["id"] + + response = client.get(f"/maps/waypoints/{wp_id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == wp_id + assert data["name"] == payload["name"] + +def test_get_waypoint_not_found(client): + response = client.get("/maps/waypoints/9999") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == "Waypoint not found" + +def test_delete_waypoint(client): + payload = {"name": "DeletePoint", "x": 5.0, "y": 5.0} + create_resp = client.post("/maps/waypoints", json=payload) + wp_id = create_resp.json()["id"] + + del_resp = client.delete(f"/maps/waypoints/{wp_id}") + assert del_resp.status_code == 204 + + get_resp = client.get(f"/maps/waypoints/{wp_id}") + assert get_resp.status_code == 404 + assert get_resp.json()["detail"] == "Waypoint not found" + +def test_delete_waypoint_not_found(client): + response = client.delete("/maps/waypoints/9999") + assert response.status_code == 404 + assert response.json()["detail"] == "Waypoint not found" + +def test_patch_waypoint(client): + payload = {"name": "PatchPoint", "x": 0.0, "y": 0.0} + create_resp = client.post("/maps/waypoints", json=payload) + wp_id = create_resp.json()["id"] + + patch_payload = {"name": "UpdatedPoint", "x": 1.1, "y": 2.2} + patch_resp = client.patch(f"/maps/waypoints/{wp_id}", json=patch_payload) + assert patch_resp.status_code == 200 + data = patch_resp.json() + assert data["id"] == wp_id + assert data["name"] == patch_payload["name"] + assert data["x"] == patch_payload["x"] + assert data["y"] == patch_payload["y"] + +def test_patch_waypoint_not_found(client): + patch_payload = {"name": "NonExistent", "x": 1.0, "y": 2.0} + response = client.patch("/maps/waypoints/9999", json=patch_payload) + assert response.status_code == 404 + assert response.json()["detail"] == "Waypoint not found" diff --git a/src/tests/test_resources.py b/src/tests/test_resources.py new file mode 100644 index 0000000..303a435 --- /dev/null +++ b/src/tests/test_resources.py @@ -0,0 +1,126 @@ +def fake_auth_dependency(): + return lambda: True + +auth_required = lambda *args, **kwargs: (lambda x: x) + +def test_create_resource_type(client): + payload = {"name": "Food"} + response = client.post("/resources/types", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Food" + assert "id" in data + +def test_list_resource_types(client): + response = client.get("/resources/types") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + +def test_get_resource_type_by_id(client): + payload = {"name": "Water"} + create_resp = client.post("/resources/types", json=payload) + type_id = create_resp.json()["id"] + + response = client.get(f"/resources/types/{type_id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == type_id + assert data["name"] == "Water" + +def test_get_resource_type_not_found(client): + response = client.get("/resources/types/9999") + assert response.status_code == 404 + assert response.json()["detail"] == "Resource type not found" + +def test_delete_resource_type(client): + payload = {"name": "Ammo"} + create_resp = client.post("/resources/types", json=payload) + type_id = create_resp.json()["id"] + + del_resp = client.delete(f"/resources/types/{type_id}") + assert del_resp.status_code == 204 + + get_resp = client.get(f"/resources/types/{type_id}") + assert get_resp.status_code == 404 + assert get_resp.json()["detail"] == "Resource type not found" + +def test_delete_resource_type_not_found(client): + response = client.delete("/resources/types/9999") + assert response.status_code == 404 + assert response.json()["detail"] == "Resource type not found" + +# -------- Resource Change Tests -------- +def test_create_resource_change(client): + # First create a resource type + type_resp = client.post("/resources/types", json={"name": "Medicine"}) + type_id = type_resp.json()["id"] + + payload = {"change": 10.5} + response = client.post(f"/resources/types/{type_id}/changes", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["change"] == 10.5 + assert "timestamp" in data + assert "id" in data + +def test_create_resource_change_type_not_found(client): + payload = {"change": -5} + response = client.post("/resources/types/9999/changes", json=payload) + assert response.status_code == 404 + assert response.json()["detail"] == "Resource type not found" + +def test_list_resource_changes(client): + # Create resource type with changes + type_resp = client.post("/resources/types", json={"name": "Fuel"}) + type_id = type_resp.json()["id"] + + client.post(f"/resources/types/{type_id}/changes", json={"change": 20}) + client.post(f"/resources/types/{type_id}/changes", json={"change": -5}) + + response = client.get(f"/resources/types/{type_id}/changes") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 2 + assert data[0]["change"] == 20 + assert data[1]["change"] == -5 + +def test_list_resource_changes_type_not_found(client): + response = client.get("/resources/types/9999/changes") + assert response.status_code == 404 + assert response.json()["detail"] == "Resource type not found" + +def test_balance_of_resource_type(client): + # Create type + type_resp = client.post("/resources/types", json={"name": "Supplies"}) + type_id = type_resp.json()["id"] + + # Add changes + client.post(f"/resources/types/{type_id}/changes", json={"change": 50}) + client.post(f"/resources/types/{type_id}/changes", json={"change": -20}) + client.post(f"/resources/types/{type_id}/changes", json={"change": 5}) + + # Get by ID with balance + resp = client.get(f"/resources/types/{type_id}") + assert resp.status_code == 200 + data = resp.json() + assert data["balance"] == 35 # 50 - 20 + 5 + +def test_list_resource_types_with_balance(client): + # Create type + type_resp = client.post("/resources/types", json={"name": "Tools"}) + type_id = type_resp.json()["id"] + + # Add changes + client.post(f"/resources/types/{type_id}/changes", json={"change": 10}) + client.post(f"/resources/types/{type_id}/changes", json={"change": 15}) + + # List all + resp = client.get("/resources/types") + print(resp.json()) + assert resp.status_code == 200 + data = resp.json() + balances = {rt["name"]: rt["balance"] for rt in data} + assert balances["Tools"] == 25 diff --git a/src/utils/jwt.py b/src/utils/jwt.py index 75c142f..86ecaa4 100644 --- a/src/utils/jwt.py +++ b/src/utils/jwt.py @@ -13,7 +13,7 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s if "sub" in to_encode: to_encode["sub"] = str(to_encode["sub"]) - expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) + expire = datetime.datetime.now() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) to_encode.update({"exp": expire}) token = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)