Compare commits
20 Commits
b9ee37b8b4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23a5083673 | ||
|
|
77846b73ef | ||
|
|
3c9a0343e9 | ||
|
|
ef93d4e5c2 | ||
|
|
f0a0667b84 | ||
|
|
aeb7a8b678 | ||
|
|
00469941dd | ||
|
|
2fe5a7f54e | ||
|
|
e860fd0607 | ||
|
|
6defbf92e0 | ||
|
|
84aa601243 | ||
|
|
4c63a663ac | ||
|
|
7005892795 | ||
|
|
64455e5c7b | ||
|
|
2f5ceec867 | ||
|
|
16afa0d363 | ||
|
|
40384c9deb | ||
|
|
aafa8ebfc9 | ||
|
|
5e2e8a9e6a | ||
|
|
917c7c898c |
4
.env
@@ -13,8 +13,8 @@ FRONTEND_HOST=http://localhost:5173
|
||||
# Environment: local, staging, production
|
||||
ENVIRONMENT=local
|
||||
|
||||
PROJECT_NAME="Full Stack FastAPI Project"
|
||||
STACK_NAME=full-stack-fastapi-project
|
||||
PROJECT_NAME="SpatialHub"
|
||||
STACK_NAME=spatialhub
|
||||
|
||||
# Backend
|
||||
BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com"
|
||||
|
||||
47
.env.production.example
Normal file
@@ -0,0 +1,47 @@
|
||||
# =============================================================================
|
||||
# 生产环境配置 — 复制为 .env.production 并填入真实值
|
||||
# =============================================================================
|
||||
|
||||
# Domain
|
||||
DOMAIN=makefire.fun
|
||||
|
||||
# Frontend host (用于后端生成邮件链接等)
|
||||
FRONTEND_HOST=https://makefire.fun
|
||||
|
||||
# Environment
|
||||
ENVIRONMENT=production
|
||||
|
||||
PROJECT_NAME="Full Stack FastAPI Project"
|
||||
STACK_NAME=full-stack-fastapi-project
|
||||
|
||||
# Backend
|
||||
BACKEND_CORS_ORIGINS="https://makefire.fun,https://api.makefire.fun"
|
||||
# ⚠️ 必须修改:运行 openssl rand -hex 32 生成
|
||||
SECRET_KEY=changethis
|
||||
FIRST_SUPERUSER=admin@makefire.fun
|
||||
# ⚠️ 必须修改:设置强密码
|
||||
FIRST_SUPERUSER_PASSWORD=changethis
|
||||
|
||||
# Emails (可选,如不需要发送邮件可留空)
|
||||
SMTP_HOST=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
EMAILS_FROM_EMAIL=info@makefire.fun
|
||||
SMTP_TLS=True
|
||||
SMTP_SSL=False
|
||||
SMTP_PORT=587
|
||||
|
||||
# Postgres — 连接 1Panel 已有的 PostgreSQL 容器
|
||||
# 在 Docker 网络 1panel-network 内,主机名为 postgresql
|
||||
POSTGRES_SERVER=postgresql
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=app
|
||||
# ⚠️ 使用已有 PG 的凭据,或为本项目创建专用用户
|
||||
POSTGRES_USER=user_ZPKMQ6
|
||||
POSTGRES_PASSWORD=password_CYmsGt
|
||||
|
||||
SENTRY_DSN=
|
||||
|
||||
# Docker images (本地构建,不需要远程 registry)
|
||||
DOCKER_IMAGE_BACKEND=backend
|
||||
DOCKER_IMAGE_FRONTEND=frontend
|
||||
87
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,87 @@
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone "http://172.17.0.1:3000/${{ github.repository }}.git" .
|
||||
git checkout "${{ github.sha }}"
|
||||
|
||||
- name: Create .env.production from secrets
|
||||
run: |
|
||||
cat > .env.production << 'ENVEOF'
|
||||
DOMAIN=${{ secrets.DOMAIN }}
|
||||
BACKEND_HOST=${{ secrets.BACKEND_HOST }}
|
||||
FRONTEND_HOST=${{ secrets.FRONTEND_HOST }}
|
||||
ENVIRONMENT=production
|
||||
PROJECT_NAME=${{ secrets.PROJECT_NAME }}
|
||||
STACK_NAME=${{ secrets.STACK_NAME }}
|
||||
BACKEND_CORS_ORIGINS=${{ secrets.BACKEND_CORS_ORIGINS }}
|
||||
SECRET_KEY=${{ secrets.SECRET_KEY }}
|
||||
FIRST_SUPERUSER=${{ secrets.FIRST_SUPERUSER }}
|
||||
FIRST_SUPERUSER_PASSWORD=${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
||||
SMTP_HOST=${{ secrets.SMTP_HOST }}
|
||||
SMTP_USER=${{ secrets.SMTP_USER }}
|
||||
SMTP_PASSWORD=${{ secrets.SMTP_PASSWORD }}
|
||||
EMAILS_FROM_EMAIL=${{ secrets.EMAILS_FROM_EMAIL }}
|
||||
SMTP_TLS=${{ secrets.SMTP_TLS }}
|
||||
SMTP_SSL=${{ secrets.SMTP_SSL }}
|
||||
SMTP_PORT=${{ secrets.SMTP_PORT }}
|
||||
POSTGRES_SERVER=${{ secrets.POSTGRES_SERVER }}
|
||||
POSTGRES_PORT=${{ secrets.POSTGRES_PORT }}
|
||||
POSTGRES_DB=${{ secrets.POSTGRES_DB }}
|
||||
POSTGRES_USER=${{ secrets.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||
DOCKER_IMAGE_BACKEND=${{ secrets.DOCKER_IMAGE_BACKEND }}
|
||||
DOCKER_IMAGE_FRONTEND=${{ secrets.DOCKER_IMAGE_FRONTEND }}
|
||||
ENVEOF
|
||||
|
||||
# Fallback defaults if image name secrets are empty
|
||||
sed -i 's/^DOCKER_IMAGE_BACKEND=$/DOCKER_IMAGE_BACKEND=backend/' .env.production
|
||||
sed -i 's/^DOCKER_IMAGE_FRONTEND=$/DOCKER_IMAGE_FRONTEND=frontend/' .env.production
|
||||
|
||||
- name: Build Docker images
|
||||
run: docker compose --env-file .env.production -f compose.prod.yml build
|
||||
|
||||
- name: Stop existing services
|
||||
run: docker compose --env-file .env.production -f compose.prod.yml down --remove-orphans || true
|
||||
|
||||
- name: Start services
|
||||
run: docker compose --env-file .env.production -f compose.prod.yml up -d
|
||||
|
||||
- name: Wait for backend health check
|
||||
run: |
|
||||
echo "Waiting for backend container health=healthy..."
|
||||
for i in $(seq 1 30); do
|
||||
status=$(docker inspect -f '{{.State.Health.Status}}' full-stack-fastapi-backend-1 2>/dev/null || echo "unknown")
|
||||
if [ "$status" = "healthy" ]; then
|
||||
echo "✅ Backend is healthy!"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i/30 - status=$status, waiting 10s..."
|
||||
sleep 10
|
||||
done
|
||||
echo "❌ Backend health check failed after 300s"
|
||||
docker compose --env-file .env.production -f compose.prod.yml logs backend
|
||||
exit 1
|
||||
|
||||
- name: Verify frontend
|
||||
run: |
|
||||
if docker compose --env-file .env.production -f compose.prod.yml ps frontend --status running | grep -q frontend; then
|
||||
echo "✅ Frontend is accessible!"
|
||||
else
|
||||
echo "❌ Frontend is not accessible"
|
||||
docker compose --env-file .env.production -f compose.prod.yml logs frontend
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Cleanup old Docker images
|
||||
run: docker image prune -f || true
|
||||
6
.github/workflows/playwright.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
- run: docker compose down -v --remove-orphans
|
||||
- name: Upload blob report to GitHub Actions Artifacts
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: blob-report-${{ matrix.shardIndex }}
|
||||
path: frontend/blob-report
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: frontend/all-blob-reports
|
||||
pattern: blob-report-*
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
run: bunx playwright merge-reports --reporter html ./all-blob-reports
|
||||
working-directory: frontend
|
||||
- name: Upload HTML report
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: html-report--attempt-${{ github.run_attempt }}
|
||||
path: frontend/playwright-report
|
||||
|
||||
2
.github/workflows/smokeshow.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install smokeshow
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: coverage-html
|
||||
path: backend/htmlcov
|
||||
|
||||
2
.github/workflows/test-backend.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
working-directory: backend
|
||||
- run: docker compose down -v --remove-orphans
|
||||
- name: Store coverage files
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-html
|
||||
path: backend/htmlcov
|
||||
|
||||
4
.gitignore
vendored
@@ -5,3 +5,7 @@ node_modules/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.DS_Store
|
||||
.env.production
|
||||
.venv/
|
||||
.playwright-cli/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for your interest in contributing to the Full Stack FastAPI Template! 🙇
|
||||
Thank you for your interest in contributing to the SpatialHub! 🙇
|
||||
|
||||
## Discussions First
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Full Stack FastAPI Template
|
||||
# SpatialHub
|
||||
|
||||
<a href="https://github.com/fastapi/full-stack-fastapi-template/actions?query=workflow%3A%22Test+Docker+Compose%22" target="_blank"><img src="https://github.com/fastapi/full-stack-fastapi-template/workflows/Test%20Docker%20Compose/badge.svg" alt="Test Docker Compose"></a>
|
||||
<a href="https://github.com/fastapi/full-stack-fastapi-template/actions?query=workflow%3A%22Test+Backend%22" target="_blank"><img src="https://github.com/fastapi/full-stack-fastapi-template/workflows/Test%20Backend/badge.svg" alt="Test Backend"></a>
|
||||
@@ -230,4 +230,4 @@ Check the file [release-notes.md](./release-notes.md).
|
||||
|
||||
## License
|
||||
|
||||
The Full Stack FastAPI Template is licensed under the terms of the MIT license.
|
||||
The SpatialHub is licensed under the terms of the MIT license.
|
||||
|
||||
@@ -42,4 +42,4 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
WORKDIR /app/backend/
|
||||
|
||||
CMD ["fastapi", "run", "--workers", "4", "app/main.py"]
|
||||
CMD ["fastapi", "run", "--workers", "2", "app/main.py"]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add location table
|
||||
|
||||
Revision ID: 0b117fab4208
|
||||
Revises: fe56fa70289e
|
||||
Create Date: 2026-03-12 11:07:24.856505
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel.sql.sqltypes
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0b117fab4208'
|
||||
down_revision = 'fe56fa70289e'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('location',
|
||||
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
|
||||
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('owner_id', sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('location')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,14 +1,15 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import items, login, private, users, utils
|
||||
from app.api.routes import locations, login, private, users, utils
|
||||
from app.core.config import settings
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(login.router)
|
||||
api_router.include_router(users.router)
|
||||
api_router.include_router(utils.router)
|
||||
api_router.include_router(items.router)
|
||||
api_router.include_router(locations.router)
|
||||
|
||||
|
||||
if settings.ENVIRONMENT == "local":
|
||||
api_router.include_router(private.router)
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlmodel import col, func, select
|
||||
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message
|
||||
|
||||
router = APIRouter(prefix="/items", tags=["items"])
|
||||
|
||||
|
||||
@router.get("/", response_model=ItemsPublic)
|
||||
def read_items(
|
||||
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieve items.
|
||||
"""
|
||||
|
||||
if current_user.is_superuser:
|
||||
count_statement = select(func.count()).select_from(Item)
|
||||
count = session.exec(count_statement).one()
|
||||
statement = (
|
||||
select(Item).order_by(col(Item.created_at).desc()).offset(skip).limit(limit)
|
||||
)
|
||||
items = session.exec(statement).all()
|
||||
else:
|
||||
count_statement = (
|
||||
select(func.count())
|
||||
.select_from(Item)
|
||||
.where(Item.owner_id == current_user.id)
|
||||
)
|
||||
count = session.exec(count_statement).one()
|
||||
statement = (
|
||||
select(Item)
|
||||
.where(Item.owner_id == current_user.id)
|
||||
.order_by(col(Item.created_at).desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
items = session.exec(statement).all()
|
||||
|
||||
return ItemsPublic(data=items, count=count)
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=ItemPublic)
|
||||
def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
|
||||
"""
|
||||
Get item by ID.
|
||||
"""
|
||||
item = session.get(Item, id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
return item
|
||||
|
||||
|
||||
@router.post("/", response_model=ItemPublic)
|
||||
def create_item(
|
||||
*, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate
|
||||
) -> Any:
|
||||
"""
|
||||
Create new item.
|
||||
"""
|
||||
item = Item.model_validate(item_in, update={"owner_id": current_user.id})
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=ItemPublic)
|
||||
def update_item(
|
||||
*,
|
||||
session: SessionDep,
|
||||
current_user: CurrentUser,
|
||||
id: uuid.UUID,
|
||||
item_in: ItemUpdate,
|
||||
) -> Any:
|
||||
"""
|
||||
Update an item.
|
||||
"""
|
||||
item = session.get(Item, id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
update_dict = item_in.model_dump(exclude_unset=True)
|
||||
item.sqlmodel_update(update_dict)
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{id}")
|
||||
def delete_item(
|
||||
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
|
||||
) -> Message:
|
||||
"""
|
||||
Delete an item.
|
||||
"""
|
||||
item = session.get(Item, id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
session.delete(item)
|
||||
session.commit()
|
||||
return Message(message="Item deleted successfully")
|
||||
119
backend/app/api/routes/locations.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlmodel import col, func, select
|
||||
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.models import (
|
||||
Location,
|
||||
LocationCreate,
|
||||
LocationPublic,
|
||||
LocationsPublic,
|
||||
LocationUpdate,
|
||||
Message,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/locations", tags=["locations"])
|
||||
|
||||
|
||||
@router.get("/", response_model=LocationsPublic)
|
||||
def read_locations(
|
||||
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieve locations.
|
||||
"""
|
||||
|
||||
if current_user.is_superuser:
|
||||
count_statement = select(func.count()).select_from(Location)
|
||||
count = session.exec(count_statement).one()
|
||||
statement = (
|
||||
select(Location).order_by(col(Location.created_at).desc()).offset(skip).limit(limit)
|
||||
)
|
||||
locations = session.exec(statement).all()
|
||||
else:
|
||||
count_statement = (
|
||||
select(func.count())
|
||||
.select_from(Location)
|
||||
.where(Location.owner_id == current_user.id)
|
||||
)
|
||||
count = session.exec(count_statement).one()
|
||||
statement = (
|
||||
select(Location)
|
||||
.where(Location.owner_id == current_user.id)
|
||||
.order_by(col(Location.created_at).desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
locations = session.exec(statement).all()
|
||||
|
||||
return LocationsPublic(data=locations, count=count)
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=LocationPublic)
|
||||
def read_location(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
|
||||
"""
|
||||
Get location by ID.
|
||||
"""
|
||||
location = session.get(Location, id)
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
if not current_user.is_superuser and (location.owner_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
return location
|
||||
|
||||
|
||||
@router.post("/", response_model=LocationPublic)
|
||||
def create_location(
|
||||
*, session: SessionDep, current_user: CurrentUser, location_in: LocationCreate
|
||||
) -> Any:
|
||||
"""
|
||||
Create new location.
|
||||
"""
|
||||
location = Location.model_validate(location_in, update={"owner_id": current_user.id})
|
||||
session.add(location)
|
||||
session.commit()
|
||||
session.refresh(location)
|
||||
return location
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=LocationPublic)
|
||||
def update_location(
|
||||
*,
|
||||
session: SessionDep,
|
||||
current_user: CurrentUser,
|
||||
id: uuid.UUID,
|
||||
location_in: LocationUpdate,
|
||||
) -> Any:
|
||||
"""
|
||||
Update a location.
|
||||
"""
|
||||
location = session.get(Location, id)
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
if not current_user.is_superuser and (location.owner_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
update_dict = location_in.model_dump(exclude_unset=True)
|
||||
location.sqlmodel_update(update_dict)
|
||||
session.add(location)
|
||||
session.commit()
|
||||
session.refresh(location)
|
||||
return location
|
||||
|
||||
|
||||
@router.delete("/{id}")
|
||||
def delete_location(
|
||||
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
|
||||
) -> Message:
|
||||
"""
|
||||
Delete a location.
|
||||
"""
|
||||
location = session.get(Location, id)
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
if not current_user.is_superuser and (location.owner_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
session.delete(location)
|
||||
session.commit()
|
||||
return Message(message="Location deleted successfully")
|
||||
@@ -13,7 +13,7 @@ from app.api.deps import (
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.models import (
|
||||
Item,
|
||||
Location,
|
||||
Message,
|
||||
UpdatePassword,
|
||||
User,
|
||||
@@ -224,7 +224,7 @@ def delete_user(
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Super users are not allowed to delete themselves"
|
||||
)
|
||||
statement = delete(Item).where(col(Item.owner_id) == user_id)
|
||||
statement = delete(Location).where(col(Location.owner_id) == user_id)
|
||||
session.exec(statement)
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
|
||||
@@ -4,7 +4,13 @@ from typing import Any
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.models import Item, ItemCreate, User, UserCreate, UserUpdate
|
||||
from app.models import (
|
||||
Location,
|
||||
LocationCreate,
|
||||
User,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
)
|
||||
|
||||
|
||||
def create_user(*, session: Session, user_create: UserCreate) -> User:
|
||||
@@ -60,9 +66,9 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None:
|
||||
return db_user
|
||||
|
||||
|
||||
def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item:
|
||||
db_item = Item.model_validate(item_in, update={"owner_id": owner_id})
|
||||
session.add(db_item)
|
||||
def create_location(*, session: Session, location_in: LocationCreate, owner_id: uuid.UUID) -> Location:
|
||||
db_location = Location.model_validate(location_in, update={"owner_id": owner_id})
|
||||
session.add(db_location)
|
||||
session.commit()
|
||||
session.refresh(db_item)
|
||||
return db_item
|
||||
session.refresh(db_location)
|
||||
return db_location
|
||||
|
||||
@@ -53,7 +53,7 @@ class User(UserBase, table=True):
|
||||
default_factory=get_datetime_utc,
|
||||
sa_type=DateTime(timezone=True), # type: ignore
|
||||
)
|
||||
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
|
||||
locations: list["Location"] = Relationship(back_populates="owner", cascade_delete=True)
|
||||
|
||||
|
||||
# Properties to return via API, id is always required
|
||||
@@ -68,23 +68,23 @@ class UsersPublic(SQLModel):
|
||||
|
||||
|
||||
# Shared properties
|
||||
class ItemBase(SQLModel):
|
||||
class LocationBase(SQLModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
# Properties to receive on item creation
|
||||
class ItemCreate(ItemBase):
|
||||
# Properties to receive on location creation
|
||||
class LocationCreate(LocationBase):
|
||||
pass
|
||||
|
||||
|
||||
# Properties to receive on item update
|
||||
class ItemUpdate(ItemBase):
|
||||
# Properties to receive on location update
|
||||
class LocationUpdate(LocationBase):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
|
||||
|
||||
|
||||
# Database model, database table inferred from class name
|
||||
class Item(ItemBase, table=True):
|
||||
class Location(LocationBase, table=True):
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
created_at: datetime | None = Field(
|
||||
default_factory=get_datetime_utc,
|
||||
@@ -93,18 +93,18 @@ class Item(ItemBase, table=True):
|
||||
owner_id: uuid.UUID = Field(
|
||||
foreign_key="user.id", nullable=False, ondelete="CASCADE"
|
||||
)
|
||||
owner: User | None = Relationship(back_populates="items")
|
||||
owner: User | None = Relationship(back_populates="locations")
|
||||
|
||||
|
||||
# Properties to return via API, id is always required
|
||||
class ItemPublic(ItemBase):
|
||||
class LocationPublic(LocationBase):
|
||||
id: uuid.UUID
|
||||
owner_id: uuid.UUID
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class ItemsPublic(SQLModel):
|
||||
data: list[ItemPublic]
|
||||
class LocationsPublic(SQLModel):
|
||||
data: list[LocationPublic]
|
||||
count: int
|
||||
|
||||
|
||||
|
||||
@@ -4,15 +4,15 @@ from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from tests.utils.item import create_random_item
|
||||
from tests.utils.location import create_random_location
|
||||
|
||||
|
||||
def test_create_item(
|
||||
def test_create_location(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
data = {"title": "Foo", "description": "Fighters"}
|
||||
data = {"title": "Test Location", "description": "A test location"}
|
||||
response = client.post(
|
||||
f"{settings.API_V1_STR}/items/",
|
||||
f"{settings.API_V1_STR}/locations/",
|
||||
headers=superuser_token_headers,
|
||||
json=data,
|
||||
)
|
||||
@@ -24,40 +24,40 @@ def test_create_item(
|
||||
assert "owner_id" in content
|
||||
|
||||
|
||||
def test_read_item(
|
||||
def test_read_location(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
item = create_random_item(db)
|
||||
location = create_random_location(db)
|
||||
response = client.get(
|
||||
f"{settings.API_V1_STR}/items/{item.id}",
|
||||
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["title"] == item.title
|
||||
assert content["description"] == item.description
|
||||
assert content["id"] == str(item.id)
|
||||
assert content["owner_id"] == str(item.owner_id)
|
||||
assert content["title"] == location.title
|
||||
assert content["description"] == location.description
|
||||
assert content["id"] == str(location.id)
|
||||
assert content["owner_id"] == str(location.owner_id)
|
||||
|
||||
|
||||
def test_read_item_not_found(
|
||||
def test_read_location_not_found(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
response = client.get(
|
||||
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
|
||||
f"{settings.API_V1_STR}/locations/{uuid.uuid4()}",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
content = response.json()
|
||||
assert content["detail"] == "Item not found"
|
||||
assert content["detail"] == "Location not found"
|
||||
|
||||
|
||||
def test_read_item_not_enough_permissions(
|
||||
def test_read_location_not_enough_permissions(
|
||||
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
item = create_random_item(db)
|
||||
location = create_random_location(db)
|
||||
response = client.get(
|
||||
f"{settings.API_V1_STR}/items/{item.id}",
|
||||
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||
headers=normal_user_token_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -65,13 +65,13 @@ def test_read_item_not_enough_permissions(
|
||||
assert content["detail"] == "Not enough permissions"
|
||||
|
||||
|
||||
def test_read_items(
|
||||
def test_read_locations(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
create_random_item(db)
|
||||
create_random_item(db)
|
||||
create_random_location(db)
|
||||
create_random_location(db)
|
||||
response = client.get(
|
||||
f"{settings.API_V1_STR}/items/",
|
||||
f"{settings.API_V1_STR}/locations/",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -79,13 +79,13 @@ def test_read_items(
|
||||
assert len(content["data"]) >= 2
|
||||
|
||||
|
||||
def test_update_item(
|
||||
def test_update_location(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
item = create_random_item(db)
|
||||
location = create_random_location(db)
|
||||
data = {"title": "Updated title", "description": "Updated description"}
|
||||
response = client.put(
|
||||
f"{settings.API_V1_STR}/items/{item.id}",
|
||||
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||
headers=superuser_token_headers,
|
||||
json=data,
|
||||
)
|
||||
@@ -93,31 +93,31 @@ def test_update_item(
|
||||
content = response.json()
|
||||
assert content["title"] == data["title"]
|
||||
assert content["description"] == data["description"]
|
||||
assert content["id"] == str(item.id)
|
||||
assert content["owner_id"] == str(item.owner_id)
|
||||
assert content["id"] == str(location.id)
|
||||
assert content["owner_id"] == str(location.owner_id)
|
||||
|
||||
|
||||
def test_update_item_not_found(
|
||||
def test_update_location_not_found(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
data = {"title": "Updated title", "description": "Updated description"}
|
||||
response = client.put(
|
||||
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
|
||||
f"{settings.API_V1_STR}/locations/{uuid.uuid4()}",
|
||||
headers=superuser_token_headers,
|
||||
json=data,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
content = response.json()
|
||||
assert content["detail"] == "Item not found"
|
||||
assert content["detail"] == "Location not found"
|
||||
|
||||
|
||||
def test_update_item_not_enough_permissions(
|
||||
def test_update_location_not_enough_permissions(
|
||||
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
item = create_random_item(db)
|
||||
location = create_random_location(db)
|
||||
data = {"title": "Updated title", "description": "Updated description"}
|
||||
response = client.put(
|
||||
f"{settings.API_V1_STR}/items/{item.id}",
|
||||
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||
headers=normal_user_token_headers,
|
||||
json=data,
|
||||
)
|
||||
@@ -126,37 +126,37 @@ def test_update_item_not_enough_permissions(
|
||||
assert content["detail"] == "Not enough permissions"
|
||||
|
||||
|
||||
def test_delete_item(
|
||||
def test_delete_location(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
item = create_random_item(db)
|
||||
location = create_random_location(db)
|
||||
response = client.delete(
|
||||
f"{settings.API_V1_STR}/items/{item.id}",
|
||||
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["message"] == "Item deleted successfully"
|
||||
assert content["message"] == "Location deleted successfully"
|
||||
|
||||
|
||||
def test_delete_item_not_found(
|
||||
def test_delete_location_not_found(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
response = client.delete(
|
||||
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
|
||||
f"{settings.API_V1_STR}/locations/{uuid.uuid4()}",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
content = response.json()
|
||||
assert content["detail"] == "Item not found"
|
||||
assert content["detail"] == "Location not found"
|
||||
|
||||
|
||||
def test_delete_item_not_enough_permissions(
|
||||
def test_delete_location_not_enough_permissions(
|
||||
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
item = create_random_item(db)
|
||||
location = create_random_location(db)
|
||||
response = client.delete(
|
||||
f"{settings.API_V1_STR}/items/{item.id}",
|
||||
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||
headers=normal_user_token_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -7,7 +7,7 @@ from sqlmodel import Session, delete
|
||||
from app.core.config import settings
|
||||
from app.core.db import engine, init_db
|
||||
from app.main import app
|
||||
from app.models import Item, User
|
||||
from app.models import Location, User
|
||||
from tests.utils.user import authentication_token_from_email
|
||||
from tests.utils.utils import get_superuser_token_headers
|
||||
|
||||
@@ -17,7 +17,7 @@ def db() -> Generator[Session, None, None]:
|
||||
with Session(engine) as session:
|
||||
init_db(session)
|
||||
yield session
|
||||
statement = delete(Item)
|
||||
statement = delete(Location)
|
||||
session.execute(statement)
|
||||
statement = delete(User)
|
||||
session.execute(statement)
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
from sqlmodel import Session
|
||||
|
||||
from app import crud
|
||||
from app.models import Item, ItemCreate
|
||||
from app.models import Location, LocationCreate
|
||||
from tests.utils.user import create_random_user
|
||||
from tests.utils.utils import random_lower_string
|
||||
|
||||
|
||||
def create_random_item(db: Session) -> Item:
|
||||
def create_random_location(db: Session) -> Location:
|
||||
user = create_random_user(db)
|
||||
owner_id = user.id
|
||||
assert owner_id is not None
|
||||
title = random_lower_string()
|
||||
description = random_lower_string()
|
||||
item_in = ItemCreate(title=title, description=description)
|
||||
return crud.create_item(session=db, item_in=item_in, owner_id=owner_id)
|
||||
location_in = LocationCreate(title=title, description=description)
|
||||
return crud.create_location(session=db, location_in=location_in, owner_id=owner_id)
|
||||
26
bun.lock
@@ -25,7 +25,7 @@
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@tanstack/react-router": "^1.157.3",
|
||||
"@tanstack/react-router": "^1.163.3",
|
||||
"@tanstack/react-router-devtools": "^1.163.3",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "1.13.5",
|
||||
@@ -388,7 +388,7 @@
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||
|
||||
"@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
|
||||
"@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
||||
|
||||
@@ -398,11 +398,11 @@
|
||||
|
||||
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.2", "", { "dependencies": { "@tanstack/query-devtools": "5.92.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.14", "react": "^18 || ^19" } }, "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg=="],
|
||||
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.157.3", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.157.3", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-nrshpAAtYRWnvJeTwItA8WwDr5oX5zOvxxcFEWIdsscLHkKsK9ED9byV4d8VfCRey+W02blBxsCKpppJfq2rnQ=="],
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.163.3", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q=="],
|
||||
|
||||
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.163.3", "", { "dependencies": { "@tanstack/router-devtools-core": "1.163.3" }, "peerDependencies": { "@tanstack/react-router": "^1.163.3", "@tanstack/router-core": "^1.163.3", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-42VMkV/2Z8ro7xzblPBRNZIEmCNXMzm2jD68G52p2qhjXm38wGpg46qneAESN9FtTQeVWk5aSXs47/jt7lkzmw=="],
|
||||
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="],
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.9.1", "", { "dependencies": { "@tanstack/store": "0.9.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA=="],
|
||||
|
||||
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
|
||||
|
||||
@@ -864,13 +864,17 @@
|
||||
|
||||
"@tailwindcss/vite/tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
||||
"@tanstack/react-router/@tanstack/router-core": ["@tanstack/router-core@1.157.3", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-r2KY/UWC4Ocxx05G7b/tLNQ7ZGX7URvA5H5P1cNbkFmi77VbOgtbW0sfz9/+9Dyh6aqHVK/Bx5kuR5jojNvrHQ=="],
|
||||
"@tanstack/react-router/@tanstack/router-core": ["@tanstack/router-core@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA=="],
|
||||
|
||||
"@tanstack/react-router-devtools/@tanstack/router-core": ["@tanstack/router-core@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA=="],
|
||||
|
||||
"@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="],
|
||||
|
||||
"@tanstack/router-core/@tanstack/history": ["@tanstack/history@1.153.2", "", {}, "sha512-TVa0Wju5w6JZGq/S74Q7TQNtKXDatJaB4NYrhMZVU9ETlkgpr35NhDfOzsCJ93P0KCo1ZoDodlFp3c54/dLsyw=="],
|
||||
|
||||
"@tanstack/router-devtools/@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.157.17", "", { "dependencies": { "@tanstack/router-devtools-core": "1.157.16" }, "peerDependencies": { "@tanstack/react-router": "^1.157.17", "@tanstack/router-core": "^1.157.16", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-ajhTEQMPK9XtgVN7KqLy9JobYbyjcbuZXc76kABA8HeUJqB98rvwdpVuB106LReeIKuTc5RLOgCrdkq2A19wpg=="],
|
||||
|
||||
"@tanstack/router-devtools-core/@tanstack/router-core": ["@tanstack/router-core@1.157.3", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-r2KY/UWC4Ocxx05G7b/tLNQ7ZGX7URvA5H5P1cNbkFmi77VbOgtbW0sfz9/+9Dyh6aqHVK/Bx5kuR5jojNvrHQ=="],
|
||||
"@tanstack/router-devtools-core/@tanstack/router-core": ["@tanstack/router-core@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA=="],
|
||||
|
||||
"@tanstack/router-generator/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
@@ -928,10 +932,18 @@
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@tanstack/react-router-devtools/@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="],
|
||||
|
||||
"@tanstack/react-router/@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="],
|
||||
|
||||
"@tanstack/router-devtools-core/@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="],
|
||||
|
||||
"@tanstack/router-devtools/@tanstack/react-router-devtools/@tanstack/router-core": ["@tanstack/router-core@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA=="],
|
||||
|
||||
"@tanstack/router-devtools/@tanstack/react-router-devtools/@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.157.16", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.157.16", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-XBJTs/kMZYK6J2zhbGucHNuypwDB1t2vi8K5To+V6dUnLGBEyfQTf01fegiF4rpL1yXgomdGnP6aTiOFgldbVg=="],
|
||||
|
||||
"c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"@tanstack/router-devtools/@tanstack/react-router-devtools/@tanstack/router-devtools-core/@tanstack/router-core": ["@tanstack/router-core@1.157.3", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-r2KY/UWC4Ocxx05G7b/tLNQ7ZGX7URvA5H5P1cNbkFmi77VbOgtbW0sfz9/+9Dyh6aqHVK/Bx5kuR5jojNvrHQ=="],
|
||||
"@tanstack/router-devtools/@tanstack/react-router-devtools/@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="],
|
||||
}
|
||||
}
|
||||
|
||||
89
compose.prod.yml
Normal file
@@ -0,0 +1,89 @@
|
||||
services:
|
||||
|
||||
prestart:
|
||||
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
networks:
|
||||
- 1panel-network
|
||||
command: bash scripts/prestart.sh
|
||||
env_file:
|
||||
- .env.production
|
||||
environment:
|
||||
- DOMAIN=${DOMAIN}
|
||||
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
|
||||
- ENVIRONMENT=${ENVIRONMENT}
|
||||
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS}
|
||||
- SECRET_KEY=${SECRET_KEY?Variable not set}
|
||||
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
|
||||
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL}
|
||||
- POSTGRES_SERVER=${POSTGRES_SERVER}
|
||||
- POSTGRES_PORT=${POSTGRES_PORT}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
||||
- SENTRY_DSN=${SENTRY_DSN}
|
||||
|
||||
backend:
|
||||
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
||||
restart: always
|
||||
networks:
|
||||
- 1panel-network
|
||||
depends_on:
|
||||
prestart:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "127.0.0.1:18000:8000"
|
||||
env_file:
|
||||
- .env.production
|
||||
environment:
|
||||
- DOMAIN=${DOMAIN}
|
||||
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
|
||||
- ENVIRONMENT=${ENVIRONMENT}
|
||||
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS}
|
||||
- SECRET_KEY=${SECRET_KEY?Variable not set}
|
||||
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
|
||||
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL}
|
||||
- POSTGRES_SERVER=${POSTGRES_SERVER}
|
||||
- POSTGRES_PORT=${POSTGRES_PORT}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
||||
- SENTRY_DSN=${SENTRY_DSN}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
|
||||
frontend:
|
||||
image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}'
|
||||
restart: always
|
||||
networks:
|
||||
- 1panel-network
|
||||
ports:
|
||||
- "127.0.0.1:3001:80"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
args:
|
||||
- VITE_API_URL=https://${BACKEND_HOST:-api.${DOMAIN?Variable not set}}
|
||||
- NODE_ENV=production
|
||||
|
||||
networks:
|
||||
1panel-network:
|
||||
external: true
|
||||
452
deploy-tencent.md
Normal file
@@ -0,0 +1,452 @@
|
||||
# 部署指南:腾讯云 + 1Panel + OpenResty + Gitea CI/CD
|
||||
|
||||
## 目录
|
||||
|
||||
1. [架构概览](#1-架构概览)
|
||||
2. [服务器准备](#2-服务器准备)
|
||||
3. [配置环境变量](#3-配置环境变量)
|
||||
4. [创建数据库](#4-创建数据库)
|
||||
5. [手动首次部署](#5-手动首次部署)
|
||||
6. [在 1Panel 中配置 OpenResty](#6-在-1panel-中配置-openresty)
|
||||
7. [DNS 解析配置](#7-dns-解析配置)
|
||||
8. [配置 Gitea Actions CI/CD](#8-配置-gitea-actions-cicd)
|
||||
9. [验证部署](#9-验证部署)
|
||||
10. [日常运维](#10-日常运维)
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构概览
|
||||
|
||||
```
|
||||
用户浏览器
|
||||
│
|
||||
▼
|
||||
OpenResty (1Panel 管理, SSL, 端口 80/443)
|
||||
│
|
||||
├── makefire.fun → 127.0.0.1:3001 (Frontend Nginx 容器)
|
||||
└── api.makefire.fun → 127.0.0.1:8000 (Backend FastAPI 容器)
|
||||
│
|
||||
▼
|
||||
PostgreSQL (1Panel 已有容器, 1panel-network)
|
||||
```
|
||||
|
||||
**关键设计决策:**
|
||||
- 后端和前端容器只绑定 `127.0.0.1`,不对外暴露,由 OpenResty 统一反代
|
||||
- 所有容器加入 `1panel-network`,可直接通过 `postgresql` 主机名访问已有数据库
|
||||
- 不使用 Traefik(用 1Panel 自带的 OpenResty 替代)
|
||||
- 不启动独立 PostgreSQL 容器(复用已有的)
|
||||
|
||||
---
|
||||
|
||||
## 2. 服务器准备
|
||||
|
||||
### 2.1 创建部署目录
|
||||
|
||||
```bash
|
||||
# SSH 登录服务器后执行
|
||||
sudo mkdir -p /opt/fastapi-app
|
||||
sudo chown $USER:$USER /opt/fastapi-app
|
||||
```
|
||||
|
||||
### 2.2 初始化 Git 仓库(在 Gitea 上)
|
||||
|
||||
1. 登录 Gitea(`http://your-server-ip:3000`)
|
||||
2. 创建新仓库,例如 `spatialhub`
|
||||
3. 在本地开发机上添加 Gitea 远程仓库:
|
||||
|
||||
```bash
|
||||
cd /Users/weifeng/Workspace/full-stack-fastapi-template
|
||||
|
||||
# 添加 Gitea 远程
|
||||
git remote add gitea http://your-server-ip:3000/your-username/spatialhub.git
|
||||
|
||||
# 推送代码
|
||||
git push gitea main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 配置环境变量
|
||||
|
||||
### 3.1 在服务器上创建生产环境文件
|
||||
|
||||
```bash
|
||||
cd /opt/fastapi-app
|
||||
|
||||
# 复制示例文件(首次需要从代码仓库获取)
|
||||
cp .env.production.example .env.production
|
||||
```
|
||||
|
||||
### 3.2 修改关键配置
|
||||
|
||||
```bash
|
||||
nano .env.production
|
||||
```
|
||||
|
||||
**必须修改的项:**
|
||||
|
||||
```bash
|
||||
# 生成强随机密钥
|
||||
SECRET_KEY=$(openssl rand -hex 32)
|
||||
echo "生成的 SECRET_KEY: $SECRET_KEY"
|
||||
|
||||
# 设置强管理员密码
|
||||
FIRST_SUPERUSER_PASSWORD=你的强密码
|
||||
```
|
||||
|
||||
> ⚠️ `SECRET_KEY` 和 `FIRST_SUPERUSER_PASSWORD` 不能使用默认值 `changethis`,否则生产环境会报错。
|
||||
|
||||
---
|
||||
|
||||
## 4. 创建数据库
|
||||
|
||||
在已有的 PostgreSQL 中为本项目创建专用数据库:
|
||||
|
||||
```bash
|
||||
# 方法一:使用 docker exec
|
||||
docker exec -it 1Panel-postgresql-bxrK psql -U user_ZPKMQ6 -c "CREATE DATABASE app;"
|
||||
|
||||
# 验证数据库是否创建成功
|
||||
docker exec -it 1Panel-postgresql-bxrK psql -U user_ZPKMQ6 -c "\l" | grep app
|
||||
```
|
||||
|
||||
如果你想创建专用用户(更安全,可选):
|
||||
|
||||
```bash
|
||||
docker exec -it 1Panel-postgresql-bxrK psql -U user_ZPKMQ6 -c "
|
||||
CREATE USER fastapi_user WITH PASSWORD 'your_strong_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE app TO fastapi_user;
|
||||
ALTER DATABASE app OWNER TO fastapi_user;
|
||||
"
|
||||
```
|
||||
|
||||
> 如果使用专用用户,记得更新 `.env.production` 中的 `POSTGRES_USER` 和 `POSTGRES_PASSWORD`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 手动首次部署
|
||||
|
||||
### 5.1 克隆代码到服务器
|
||||
|
||||
```bash
|
||||
cd /opt/fastapi-app
|
||||
git clone http://localhost:3000/your-username/spatialhub.git .
|
||||
# 或者如果已经有代码
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
### 5.2 复制环境变量文件
|
||||
|
||||
确保 `.env.production` 在 `/opt/fastapi-app/` 目录下。
|
||||
|
||||
### 5.3 构建和启动
|
||||
|
||||
```bash
|
||||
cd /opt/fastapi-app
|
||||
|
||||
# 构建镜像
|
||||
docker compose -f compose.prod.yml build
|
||||
|
||||
# 启动服务
|
||||
docker compose -f compose.prod.yml up -d
|
||||
|
||||
# 查看日志
|
||||
docker compose -f compose.prod.yml logs -f
|
||||
```
|
||||
|
||||
### 5.4 验证容器状态
|
||||
|
||||
```bash
|
||||
# 查看运行状态
|
||||
docker compose -f compose.prod.yml ps
|
||||
|
||||
# 测试后端
|
||||
curl http://127.0.0.1:8000/api/v1/utils/health-check/
|
||||
|
||||
# 测试前端
|
||||
curl -I http://127.0.0.1:3001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 在 1Panel 中配置 OpenResty
|
||||
|
||||
### 6.1 创建前端网站
|
||||
|
||||
1. 打开 1Panel → **网站** → **创建网站**
|
||||
2. 选择 **反向代理**
|
||||
3. 配置:
|
||||
- **主域名**: `makefire.fun`
|
||||
- **代理地址**: `http://127.0.0.1:3001`
|
||||
4. 点击创建
|
||||
|
||||
### 6.2 创建后端 API 网站
|
||||
|
||||
1. **网站** → **创建网站** → **反向代理**
|
||||
2. 配置:
|
||||
- **主域名**: `api.makefire.fun`
|
||||
- **代理地址**: `http://127.0.0.1:8000`
|
||||
3. 点击创建
|
||||
|
||||
### 6.3 配置 SSL 证书
|
||||
|
||||
对每个网站:
|
||||
|
||||
1. 点击网站名称进入设置
|
||||
2. 选择 **HTTPS** 标签
|
||||
3. 选择 **申请证书** → **Let's Encrypt**
|
||||
4. 勾选 **自动续签**
|
||||
5. 勾选 **HTTP → HTTPS 强制跳转**
|
||||
|
||||
### 6.4 修改后端网站配置(可选优化)
|
||||
|
||||
进入 `api.makefire.fun` 网站设置 → **配置文件**,在 `location /` 块中添加:
|
||||
|
||||
```nginx
|
||||
# WebSocket 支持
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# 请求体大小限制
|
||||
client_max_body_size 10m;
|
||||
|
||||
# 超时
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
```
|
||||
|
||||
> 完整参考配置见项目根目录的 `openresty-example.conf`。
|
||||
|
||||
---
|
||||
|
||||
## 7. DNS 解析配置
|
||||
|
||||
在你的域名 DNS 管理处(腾讯云 DNS 或其他)添加:
|
||||
|
||||
| 记录类型 | 主机记录 | 记录值 | TTL |
|
||||
|---------|---------|--------|-----|
|
||||
| A | @ | 你的服务器 IP | 600 |
|
||||
| A | api | 你的服务器 IP | 600 |
|
||||
|
||||
> 如果使用腾讯云域名,进入 **DNS 解析 DNSPod** 配置。
|
||||
|
||||
---
|
||||
|
||||
## 8. 配置 Gitea Actions CI/CD
|
||||
|
||||
### 8.1 安装 Gitea Actions Runner
|
||||
|
||||
```bash
|
||||
# 1. 下载 Gitea Actions Runner
|
||||
# 访问 https://gitea.com/gitea/act_runner/releases 获取最新版本
|
||||
wget https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64
|
||||
chmod +x act_runner-0.2.11-linux-amd64
|
||||
sudo mv act_runner-0.2.11-linux-amd64 /usr/local/bin/act_runner
|
||||
|
||||
# 2. 生成配置文件
|
||||
cd /opt
|
||||
act_runner generate-config > act_runner_config.yaml
|
||||
```
|
||||
|
||||
### 8.2 修改 Runner 配置
|
||||
|
||||
编辑 `/opt/act_runner_config.yaml`,关键修改:
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
# 标签,决定 workflow 中 runs-on 可以匹配的值
|
||||
labels:
|
||||
- "ubuntu-latest:host"
|
||||
# ↑ 使用 host 模式,直接在服务器上运行(不在 Docker 中套 Docker)
|
||||
```
|
||||
|
||||
> **为什么用 `host` 模式?** 因为 workflow 需要执行 `docker compose` 命令来管理服务器上的容器。若在容器中运行,需要额外配置 Docker-in-Docker,对低内存服务器更加不友好。
|
||||
|
||||
### 8.3 注册 Runner
|
||||
|
||||
```bash
|
||||
# 1. 在 Gitea 中获取 Runner Token
|
||||
# 进入仓库 → Settings → Actions → Runners → 点击 "Create new runner"
|
||||
# 复制显示的 Token
|
||||
|
||||
# 2. 注册
|
||||
act_runner register \
|
||||
--instance http://localhost:3000 \
|
||||
--token YOUR_RUNNER_TOKEN \
|
||||
--name my-runner \
|
||||
--labels "ubuntu-latest:host" \
|
||||
--config /opt/act_runner_config.yaml \
|
||||
--no-interactive
|
||||
|
||||
# 3. 启动(测试)
|
||||
act_runner daemon --config /opt/act_runner_config.yaml
|
||||
```
|
||||
|
||||
### 8.4 设置为系统服务(推荐)
|
||||
|
||||
```bash
|
||||
sudo tee /etc/systemd/system/gitea-runner.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Gitea Actions Runner
|
||||
After=network.target docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/fastapi-app
|
||||
ExecStart=/usr/local/bin/act_runner daemon --config /opt/act_runner_config.yaml
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable gitea-runner
|
||||
sudo systemctl start gitea-runner
|
||||
|
||||
# 查看状态
|
||||
sudo systemctl status gitea-runner
|
||||
```
|
||||
|
||||
### 8.5 配置 Gitea Secrets
|
||||
|
||||
在 Gitea 仓库中配置环境变量密钥:
|
||||
|
||||
1. 进入仓库 → **Settings** → **Actions** → **Secrets**
|
||||
2. 添加以下 Secrets:
|
||||
|
||||
| Secret 名称 | 值 |
|
||||
|-------------|-----|
|
||||
| `DOMAIN` | `makefire.fun` |
|
||||
| `FRONTEND_HOST` | `https://makefire.fun` |
|
||||
| `PROJECT_NAME` | `SpatialHub` |
|
||||
| `STACK_NAME` | `spatialhub` |
|
||||
| `BACKEND_CORS_ORIGINS` | `https://makefire.fun,https://api.makefire.fun` |
|
||||
| `SECRET_KEY` | *(用 `openssl rand -hex 32` 生成)* |
|
||||
| `FIRST_SUPERUSER` | `admin@makefire.fun` |
|
||||
| `FIRST_SUPERUSER_PASSWORD` | *(你的强密码)* |
|
||||
| `SMTP_HOST` | *(留空或填写)* |
|
||||
| `SMTP_USER` | *(留空或填写)* |
|
||||
| `SMTP_PASSWORD` | *(留空或填写)* |
|
||||
| `EMAILS_FROM_EMAIL` | `info@makefire.fun` |
|
||||
| `SMTP_TLS` | `True` |
|
||||
| `SMTP_SSL` | `False` |
|
||||
| `SMTP_PORT` | `587` |
|
||||
| `POSTGRES_SERVER` | `postgresql` |
|
||||
| `POSTGRES_PORT` | `5432` |
|
||||
| `POSTGRES_DB` | `app` |
|
||||
| `POSTGRES_USER` | `user_ZPKMQ6` |
|
||||
| `POSTGRES_PASSWORD` | `password_CYmsGt` |
|
||||
| `SENTRY_DSN` | *(留空)* |
|
||||
| `DOCKER_IMAGE_BACKEND` | `backend` |
|
||||
| `DOCKER_IMAGE_FRONTEND` | `frontend` |
|
||||
|
||||
### 8.6 启用 Gitea Actions
|
||||
|
||||
1. 进入仓库 → **Settings** → **Repository**
|
||||
2. 确保 **Actions** 功能已启用
|
||||
3. 如果 Gitea 全局未启用 Actions,需要在 Gitea 配置文件中添加:
|
||||
|
||||
```ini
|
||||
; 在 /opt/1panel/apps/gitea/gitea/data/gitea/conf/app.ini 中
|
||||
[actions]
|
||||
ENABLED = true
|
||||
```
|
||||
|
||||
修改后重启 Gitea 容器:
|
||||
|
||||
```bash
|
||||
docker restart 1Panel-gitea-FSXv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 验证部署
|
||||
|
||||
### 9.1 本地访问测试
|
||||
|
||||
```bash
|
||||
# 后端健康检查
|
||||
curl http://127.0.0.1:8000/api/v1/utils/health-check/
|
||||
|
||||
# 前端页面
|
||||
curl -I http://127.0.0.1:3001
|
||||
```
|
||||
|
||||
### 9.2 域名访问测试
|
||||
|
||||
```bash
|
||||
# 前端
|
||||
curl -I https://makefire.fun
|
||||
|
||||
# 后端 API 文档
|
||||
curl -I https://api.makefire.fun/docs
|
||||
|
||||
# 后端健康检查
|
||||
curl https://api.makefire.fun/api/v1/utils/health-check/
|
||||
```
|
||||
|
||||
### 9.3 CI/CD 测试
|
||||
|
||||
```bash
|
||||
# 在本地开发机上
|
||||
cd /Users/weifeng/Workspace/full-stack-fastapi-template
|
||||
echo "# test" >> README.md
|
||||
git add .
|
||||
git commit -m "test: trigger CI/CD"
|
||||
git push gitea main
|
||||
```
|
||||
|
||||
然后在 Gitea 仓库的 **Actions** 标签中查看运行状态。
|
||||
|
||||
---
|
||||
|
||||
## 10. 日常运维
|
||||
|
||||
### 查看日志
|
||||
|
||||
```bash
|
||||
cd /opt/fastapi-app
|
||||
docker compose -f compose.prod.yml logs -f backend # 后端日志
|
||||
docker compose -f compose.prod.yml logs -f frontend # 前端日志
|
||||
docker compose -f compose.prod.yml logs -f # 全部日志
|
||||
```
|
||||
|
||||
### 重启服务
|
||||
|
||||
```bash
|
||||
docker compose -f compose.prod.yml restart backend
|
||||
docker compose -f compose.prod.yml restart frontend
|
||||
```
|
||||
|
||||
### 手动重新部署
|
||||
|
||||
```bash
|
||||
cd /opt/fastapi-app
|
||||
git pull origin main
|
||||
docker compose -f compose.prod.yml build
|
||||
docker compose -f compose.prod.yml down
|
||||
docker compose -f compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 清理 Docker 资源
|
||||
|
||||
```bash
|
||||
# 清理无用镜像(释放磁盘空间)
|
||||
docker image prune -f
|
||||
docker system prune -f
|
||||
```
|
||||
|
||||
### 数据库备份
|
||||
|
||||
```bash
|
||||
# 备份
|
||||
docker exec 1Panel-postgresql-bxrK pg_dump -U user_ZPKMQ6 app > backup_$(date +%Y%m%d).sql
|
||||
|
||||
# 恢复
|
||||
docker exec -i 1Panel-postgresql-bxrK psql -U user_ZPKMQ6 app < backup_20260311.sql
|
||||
```
|
||||
@@ -2,10 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Full Stack FastAPI Project</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/images/favicon.png" />
|
||||
<title>SpatialHub</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/images/spatialhub-icon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@tanstack/react-router": "^1.157.3",
|
||||
"@tanstack/react-router": "^1.163.3",
|
||||
"@tanstack/react-router-devtools": "^1.163.3",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "1.13.5",
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="500"
|
||||
height="500"
|
||||
viewBox="0 0 132.29167 132.29166"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
sodipodi:docname="icon-white-nomargin-transparent.svg"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
inkscape:export-filename="icon-teal-nomargin-transparent-500.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1720"
|
||||
inkscape:window-height="1371"
|
||||
id="namedview10"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.73657628"
|
||||
inkscape:cx="448.01877"
|
||||
inkscape:cy="-91.640203"
|
||||
inkscape:window-x="1720"
|
||||
inkscape:window-y="32"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="g1"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
id="g2149">
|
||||
<g
|
||||
id="g2141">
|
||||
<g
|
||||
id="g1">
|
||||
<path
|
||||
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
|
||||
style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.28098;stop-color:#000000"
|
||||
d="M 66.145833 0 A 66.145836 65.931923 0 0 0 0 65.931893 A 66.145836 65.931923 0 0 0 66.145833 131.86379 A 66.145836 65.931923 0 0 0 132.29167 65.931893 A 66.145836 65.931923 0 0 0 66.145833 0 z M 61.581771 29.853992 L 103.20042 29.853992 L 61.410205 59.222742 L 89.976937 59.222742 L 29.091248 102.00979 L 42.315763 72.641044 L 48.358289 59.222742 L 61.581771 29.853992 z " />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<rect
|
||||
y="-49.422424"
|
||||
x="-51.908718"
|
||||
height="162.82199"
|
||||
width="451.52316"
|
||||
id="rect824"
|
||||
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1,77 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="500"
|
||||
height="500"
|
||||
viewBox="0 0 132.29167 132.29166"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
sodipodi:docname="icon-teal-nomargin-transparent.svg"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
inkscape:export-filename="icon-teal-nomargin-192.png"
|
||||
inkscape:export-xdpi="36.863998"
|
||||
inkscape:export-ydpi="36.863998"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1720"
|
||||
inkscape:window-height="1371"
|
||||
id="namedview10"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.73657628"
|
||||
inkscape:cx="448.01877"
|
||||
inkscape:cy="-91.640203"
|
||||
inkscape:window-x="1720"
|
||||
inkscape:window-y="32"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="g1"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
id="g2149">
|
||||
<g
|
||||
id="g2141">
|
||||
<g
|
||||
id="g1">
|
||||
<path
|
||||
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
|
||||
style="fill:#009688;fill-opacity:0.980392;stroke:none;stroke-width:0.28098;stop-color:#000000"
|
||||
d="M 66.145833 0 A 66.145836 65.931923 0 0 0 0 65.931893 A 66.145836 65.931923 0 0 0 66.145833 131.86379 A 66.145836 65.931923 0 0 0 132.29167 65.931893 A 66.145836 65.931923 0 0 0 66.145833 0 z M 61.581771 29.853992 L 103.20042 29.853992 L 61.410205 59.222742 L 89.976937 59.222742 L 29.091248 102.00979 L 42.315763 72.641044 L 48.358289 59.222742 L 61.581771 29.853992 z " />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<rect
|
||||
y="-49.422424"
|
||||
x="-51.908718"
|
||||
height="162.82199"
|
||||
width="451.52316"
|
||||
id="rect824"
|
||||
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1,83 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="341.26324mm"
|
||||
height="63.977489mm"
|
||||
viewBox="0 0 341.26324 63.977485"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
sodipodi:docname="logo-white-nomargin-vector.svg"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
inkscape:export-filename="logo-white-margin-vector.png"
|
||||
inkscape:export-xdpi="57.604134"
|
||||
inkscape:export-ydpi="57.604134"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1720"
|
||||
inkscape:window-height="1371"
|
||||
id="namedview10"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.73657628"
|
||||
inkscape:cx="644.8755"
|
||||
inkscape:cy="95.713101"
|
||||
inkscape:window-x="1720"
|
||||
inkscape:window-y="32"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="g2141"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
id="g2149"
|
||||
transform="translate(2.5752783e-4,1.1668595e-4)">
|
||||
<g
|
||||
id="g2141">
|
||||
<g
|
||||
id="g1">
|
||||
<path
|
||||
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
|
||||
style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.136325;stop-color:#000000"
|
||||
d="M 32.092357,-1.166849e-4 A 32.092354,31.988569 0 0 0 -2.5752734e-4,31.988628 32.092354,31.988569 0 0 0 32.092357,63.977374 32.092354,31.988569 0 0 0 64.184455,31.988628 32.092354,31.988569 0 0 0 32.092357,-1.166849e-4 Z M 29.878022,14.484271 H 50.070588 L 29.794823,28.73353 H 43.654442 L 14.114126,49.49247 20.530789,35.243727 23.462393,28.73353 Z" />
|
||||
<path
|
||||
style="font-size:79.7151px;line-height:1.25;font-family:Ubuntu;-inkscape-font-specification:Ubuntu;letter-spacing:0px;word-spacing:0px;fill:#ffffff;stroke-width:1.99288"
|
||||
d="M 89.762163,59.410606 V 4.1680399 H 121.88735 V 9.5089518 H 95.979941 V 28.082571 h 22.957949 v 5.340912 H 95.979941 v 25.987123 z m 51.017587,0.876867 q -4.46405,0 -7.97152,-1.275442 -3.50746,-1.275442 -5.50034,-4.145185 -1.99288,-2.869744 -1.99288,-7.572935 0,-4.543761 2.23203,-7.33379 2.23202,-2.869743 6.13806,-4.145185 3.98576,-1.275442 8.92809,-1.275442 2.23203,0 4.70319,0.398576 2.47117,0.398575 3.10889,0.717436 v -2.391453 q 0,-2.710314 -0.71743,-5.181482 -0.63772,-2.550883 -2.71032,-4.145186 -2.07259,-1.594302 -6.29749,-1.594302 -4.38433,0 -6.69607,0.637721 -2.31174,0.637721 -3.42775,1.036297 l -0.79715,-5.101767 q 1.43487,-0.637721 4.38433,-1.195727 2.94946,-0.558005 6.93522,-0.558005 5.65977,0 8.92809,1.992877 3.34803,1.992878 4.7829,5.500342 1.51459,3.42775 1.51459,7.891796 v 25.907408 q -1.67402,0.398576 -5.97863,1.116012 -4.30462,0.717436 -9.56581,0.717436 z m 0.87686,-5.101767 q 2.79003,0 5.02205,-0.15943 2.23203,-0.239146 3.74661,-0.558006 V 40.597842 q -0.79715,-0.398575 -2.71031,-0.797151 -1.91316,-0.398576 -4.94234,-0.398576 -2.55088,0 -5.18148,0.558006 -2.6306,0.558006 -4.46404,2.232023 -1.75374,1.674017 -1.75374,5.022052 0,4.464045 2.79003,6.217778 2.79003,1.753732 7.49322,1.753732 z m 36.4298,5.181482 q -5.34092,0 -8.37009,-0.956582 -2.94946,-0.876866 -3.98575,-1.355156 l 1.43487,-5.261197 q 0.87686,0.31886 3.58718,1.355157 2.71031,1.036296 7.33379,1.036296 4.38433,0 7.09464,-1.355157 2.71031,-1.355157 2.71031,-4.703191 0,-2.152308 -0.95658,-3.427749 -0.95658,-1.355157 -3.10889,-2.471169 -2.07259,-1.116011 -5.73948,-2.550883 -3.10889,-1.275442 -5.73949,-2.710313 -2.6306,-1.434872 -4.30462,-3.666895 -1.5943,-2.232023 -1.5943,-5.739488 0,-3.427749 1.75373,-5.978632 1.75374,-2.550884 4.94234,-3.985755 3.26832,-1.434872 7.73237,-1.434872 4.14518,0 7.01492,0.717436 2.86975,0.717436 4.06547,1.275441 l -1.35515,5.181482 q -1.0363,-0.558006 -3.34804,-1.275442 -2.31173,-0.797151 -6.53663,-0.797151 -3.34804,0 -5.81921,1.434872 -2.47117,1.355157 -2.47117,4.384331 0,2.152308 1.0363,3.507464 1.0363,1.275442 3.10889,2.311738 2.07259,1.036297 5.10177,2.232023 3.42775,1.355157 6.13806,2.869744 2.79003,1.434872 4.46405,3.74661 1.67401,2.311738 1.67401,6.138063 0,3.74661 -1.91316,6.377208 -1.91316,2.550883 -5.50034,3.826325 -3.50747,1.275442 -8.4498,1.275442 z m 39.21967,-0.07972 q -5.2612,0 -8.29037,-1.833448 -3.02917,-1.833447 -4.30461,-5.500342 -1.19573,-3.666895 -1.19573,-9.167237 V 6.1609175 L 209.494,5.1246211 V 18.118183 h 16.18217 v 5.022051 H 209.494 v 21.124503 q 0,4.38433 0.95658,6.696068 1.0363,2.311738 2.86975,3.188605 1.91316,0.797151 4.46404,0.797151 3.02918,0 5.02205,-0.717436 2.0726,-0.717436 3.26832,-1.275442 l 1.27544,4.862621 q -1.19572,0.717436 -3.98575,1.594302 -2.71031,0.876867 -6.05835,0.876867 z m 11.95723,-0.876867 q 4.30462,-11.55869 7.8918,-21.044787 3.58718,-9.486097 7.01493,-17.776468 3.50746,-8.370086 7.4135,-16.4213111 h 5.58006 q 2.86974,6.0583481 5.50034,12.2761261 2.71032,6.138063 5.34091,12.754416 2.6306,6.616354 5.42063,14.109574 2.86975,7.413504 6.05835,16.10245 h -6.77578 q -1.43488,-3.985755 -2.79003,-7.572934 -1.35516,-3.666895 -2.55089,-7.17436 h -26.30598 q -1.27544,3.507465 -2.6306,7.17436 -1.35516,3.587179 -2.71031,7.572934 z M 242.8946,39.402115 h 22.63909 q -1.51459,-3.985755 -2.94946,-7.732365 -1.43487,-3.746609 -2.86975,-7.254074 -1.35515,-3.507464 -2.79002,-6.775784 -1.35516,-3.348034 -2.79003,-6.536638 -1.35516,3.188604 -2.79003,6.536638 -1.35516,3.26832 -2.79003,6.775784 -1.35516,3.507465 -2.79003,7.254074 -1.43487,3.74661 -2.86974,7.732365 z m 44.481,20.008491 V 5.2043362 q 3.02917,-0.797151 6.93521,-1.1160114 3.98576,-0.3985755 7.33379,-0.3985755 11.71812,0 17.53732,4.5437609 5.89892,4.4640458 5.89892,12.7544168 0,6.297493 -2.94946,10.123818 -2.86974,3.826325 -8.37008,5.580057 -5.42063,1.674017 -13.07328,1.674017 h -7.09465 v 21.044787 z m 6.21777,-26.385699 h 6.53664 q 5.65978,0 9.80496,-0.956581 4.14519,-1.036296 6.37721,-3.666895 2.31174,-2.630598 2.31174,-7.493219 0,-4.703192 -2.39146,-7.254075 -2.39145,-2.550883 -6.21777,-3.587179 -3.82633,-1.0362968 -8.13094,-1.0362968 -2.79003,0 -4.86263,0.2391453 -1.99287,0.1594302 -3.42775,0.3188604 z M 335.0452,59.410606 V 4.1680399 h 6.21778 V 59.410606 Z"
|
||||
id="text979-3"
|
||||
aria-label="FastAPI" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<rect
|
||||
y="-49.422306"
|
||||
x="-51.908459"
|
||||
height="162.82199"
|
||||
width="451.52316"
|
||||
id="rect824"
|
||||
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.0 KiB |
@@ -1,91 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="341.26297mm"
|
||||
height="63.977139mm"
|
||||
viewBox="0 0 341.26297 63.977134"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
sodipodi:docname="logo-teal-nomargin-vector.svg"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1720"
|
||||
inkscape:window-height="1371"
|
||||
id="namedview10"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.73657628"
|
||||
inkscape:cx="448.01877"
|
||||
inkscape:cy="-91.640203"
|
||||
inkscape:window-x="1720"
|
||||
inkscape:window-y="32"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="g1"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
id="g2149">
|
||||
<g
|
||||
id="g2141">
|
||||
<g
|
||||
id="g1">
|
||||
<g
|
||||
id="g2106"
|
||||
transform="matrix(0.96564264,0,0,0.96251987,-899.3295,194.86874)">
|
||||
<circle
|
||||
style="fill:#009688;fill-opacity:0.980392;stroke:none;stroke-width:0.141404;stop-color:#000000"
|
||||
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
|
||||
cx="964.56165"
|
||||
cy="-169.22266"
|
||||
r="33.234192"
|
||||
inkscape:export-xdpi="1543.8315"
|
||||
inkscape:export-ydpi="1543.8315" />
|
||||
<path
|
||||
id="rect1249-6-3-4-4-3-6-6-1-2"
|
||||
style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.146895;stop-color:#000000"
|
||||
d="m 962.2685,-187.40837 -6.64403,14.80375 -3.03599,6.76393 -6.64456,14.80375 30.59142,-21.56768 h -14.35312 l 20.99715,-14.80375 z" />
|
||||
</g>
|
||||
<path
|
||||
style="font-size:79.7151px;line-height:1.25;font-family:Ubuntu;-inkscape-font-specification:Ubuntu;letter-spacing:0px;word-spacing:0px;fill:#009688;stroke-width:1.99288"
|
||||
d="M 89.762163,59.410606 V 4.1680399 H 121.88735 V 9.5089518 H 95.979941 V 28.082571 h 22.957949 v 5.340912 H 95.979941 v 25.987123 z m 51.017587,0.876867 q -4.46405,0 -7.97152,-1.275442 -3.50746,-1.275442 -5.50034,-4.145185 -1.99288,-2.869744 -1.99288,-7.572935 0,-4.543761 2.23203,-7.33379 2.23202,-2.869743 6.13806,-4.145185 3.98576,-1.275442 8.92809,-1.275442 2.23203,0 4.70319,0.398576 2.47117,0.398575 3.10889,0.717436 v -2.391453 q 0,-2.710314 -0.71743,-5.181482 -0.63772,-2.550883 -2.71032,-4.145186 -2.07259,-1.594302 -6.29749,-1.594302 -4.38433,0 -6.69607,0.637721 -2.31174,0.637721 -3.42775,1.036297 l -0.79715,-5.101767 q 1.43487,-0.637721 4.38433,-1.195727 2.94946,-0.558005 6.93522,-0.558005 5.65977,0 8.92809,1.992877 3.34803,1.992878 4.7829,5.500342 1.51459,3.42775 1.51459,7.891796 v 25.907408 q -1.67402,0.398576 -5.97863,1.116012 -4.30462,0.717436 -9.56581,0.717436 z m 0.87686,-5.101767 q 2.79003,0 5.02205,-0.15943 2.23203,-0.239146 3.74661,-0.558006 V 40.597842 q -0.79715,-0.398575 -2.71031,-0.797151 -1.91316,-0.398576 -4.94234,-0.398576 -2.55088,0 -5.18148,0.558006 -2.6306,0.558006 -4.46404,2.232023 -1.75374,1.674017 -1.75374,5.022052 0,4.464045 2.79003,6.217778 2.79003,1.753732 7.49322,1.753732 z m 36.4298,5.181482 q -5.34092,0 -8.37009,-0.956582 -2.94946,-0.876866 -3.98575,-1.355156 l 1.43487,-5.261197 q 0.87686,0.31886 3.58718,1.355157 2.71031,1.036296 7.33379,1.036296 4.38433,0 7.09464,-1.355157 2.71031,-1.355157 2.71031,-4.703191 0,-2.152308 -0.95658,-3.427749 -0.95658,-1.355157 -3.10889,-2.471169 -2.07259,-1.116011 -5.73948,-2.550883 -3.10889,-1.275442 -5.73949,-2.710313 -2.6306,-1.434872 -4.30462,-3.666895 -1.5943,-2.232023 -1.5943,-5.739488 0,-3.427749 1.75373,-5.978632 1.75374,-2.550884 4.94234,-3.985755 3.26832,-1.434872 7.73237,-1.434872 4.14518,0 7.01492,0.717436 2.86975,0.717436 4.06547,1.275441 l -1.35515,5.181482 q -1.0363,-0.558006 -3.34804,-1.275442 -2.31173,-0.797151 -6.53663,-0.797151 -3.34804,0 -5.81921,1.434872 -2.47117,1.355157 -2.47117,4.384331 0,2.152308 1.0363,3.507464 1.0363,1.275442 3.10889,2.311738 2.07259,1.036297 5.10177,2.232023 3.42775,1.355157 6.13806,2.869744 2.79003,1.434872 4.46405,3.74661 1.67401,2.311738 1.67401,6.138063 0,3.74661 -1.91316,6.377208 -1.91316,2.550883 -5.50034,3.826325 -3.50747,1.275442 -8.4498,1.275442 z m 39.21967,-0.07972 q -5.2612,0 -8.29037,-1.833448 -3.02917,-1.833447 -4.30461,-5.500342 -1.19573,-3.666895 -1.19573,-9.167237 V 6.1609175 L 209.494,5.1246211 V 18.118183 h 16.18217 v 5.022051 H 209.494 v 21.124503 q 0,4.38433 0.95658,6.696068 1.0363,2.311738 2.86975,3.188605 1.91316,0.797151 4.46404,0.797151 3.02918,0 5.02205,-0.717436 2.0726,-0.717436 3.26832,-1.275442 l 1.27544,4.862621 q -1.19572,0.717436 -3.98575,1.594302 -2.71031,0.876867 -6.05835,0.876867 z m 11.95723,-0.876867 q 4.30462,-11.55869 7.8918,-21.044787 3.58718,-9.486097 7.01493,-17.776468 3.50746,-8.370086 7.4135,-16.4213111 h 5.58006 q 2.86974,6.0583481 5.50034,12.2761261 2.71032,6.138063 5.34091,12.754416 2.6306,6.616354 5.42063,14.109574 2.86975,7.413504 6.05835,16.10245 h -6.77578 q -1.43488,-3.985755 -2.79003,-7.572934 -1.35516,-3.666895 -2.55089,-7.17436 h -26.30598 q -1.27544,3.507465 -2.6306,7.17436 -1.35516,3.587179 -2.71031,7.572934 z M 242.8946,39.402115 h 22.63909 q -1.51459,-3.985755 -2.94946,-7.732365 -1.43487,-3.746609 -2.86975,-7.254074 -1.35515,-3.507464 -2.79002,-6.775784 -1.35516,-3.348034 -2.79003,-6.536638 -1.35516,3.188604 -2.79003,6.536638 -1.35516,3.26832 -2.79003,6.775784 -1.35516,3.507465 -2.79003,7.254074 -1.43487,3.74661 -2.86974,7.732365 z m 44.481,20.008491 V 5.2043362 q 3.02917,-0.797151 6.93521,-1.1160114 3.98576,-0.3985755 7.33379,-0.3985755 11.71812,0 17.53732,4.5437609 5.89892,4.4640458 5.89892,12.7544168 0,6.297493 -2.94946,10.123818 -2.86974,3.826325 -8.37008,5.580057 -5.42063,1.674017 -13.07328,1.674017 h -7.09465 v 21.044787 z m 6.21777,-26.385699 h 6.53664 q 5.65978,0 9.80496,-0.956581 4.14519,-1.036296 6.37721,-3.666895 2.31174,-2.630598 2.31174,-7.493219 0,-4.703192 -2.39146,-7.254075 -2.39145,-2.550883 -6.21777,-3.587179 -3.82633,-1.0362968 -8.13094,-1.0362968 -2.79003,0 -4.86263,0.2391453 -1.99287,0.1594302 -3.42775,0.3188604 z M 335.0452,59.410606 V 4.1680399 h 6.21778 V 59.410606 Z"
|
||||
id="text979-3"
|
||||
aria-label="FastAPI" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<rect
|
||||
y="-49.422424"
|
||||
x="-51.908718"
|
||||
height="162.82199"
|
||||
width="451.52316"
|
||||
id="rect824"
|
||||
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
8
frontend/public/assets/images/spatialhub-icon-light.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="40" cy="40" r="35" fill="#009688" />
|
||||
<g fill="#ffffff">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M 40 18 C 30 18 22 26 22 36 C 22 48 40 63 40 63 C 40 63 58 48 58 36 C 58 26 50 18 40 18 Z M 40 43 C 36.134 43 33 39.866 33 36 C 33 32.134 36.134 29 40 29 C 43.866 29 47 32.134 47 36 C 47 39.866 43.866 43 40 43 Z" />
|
||||
<ellipse cx="40" cy="40" rx="27" ry="10" fill="none" stroke="#ffffff" stroke-width="2.5" transform="rotate(-25 40 40)" />
|
||||
<circle cx="15.5" cy="51.5" r="3.5" fill="#ffffff" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 599 B |
8
frontend/public/assets/images/spatialhub-icon.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="40" cy="40" r="35" fill="#009688" />
|
||||
<g fill="#ffffff">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M 40 18 C 30 18 22 26 22 36 C 22 48 40 63 40 63 C 40 63 58 48 58 36 C 58 26 50 18 40 18 Z M 40 43 C 36.134 43 33 39.866 33 36 C 33 32.134 36.134 29 40 29 C 43.866 29 47 32.134 47 36 C 47 39.866 43.866 43 40 43 Z" />
|
||||
<ellipse cx="40" cy="40" rx="27" ry="10" fill="none" stroke="#ffffff" stroke-width="2.5" transform="rotate(-25 40 40)" />
|
||||
<circle cx="15.5" cy="51.5" r="3.5" fill="#ffffff" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 599 B |
17
frontend/public/assets/images/spatialhub-logo-light.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg viewBox="0 0 450 80" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text {
|
||||
font-family: 'Ubuntu', 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 58px;
|
||||
font-weight: 600;
|
||||
fill: #ffffff;
|
||||
}
|
||||
</style>
|
||||
<circle cx="40" cy="40" r="35" fill="#009688" />
|
||||
<g fill="#ffffff">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M 40 18 C 30 18 22 26 22 36 C 22 48 40 63 40 63 C 40 63 58 48 58 36 C 58 26 50 18 40 18 Z M 40 43 C 36.134 43 33 39.866 33 36 C 33 32.134 36.134 29 40 29 C 43.866 29 47 32.134 47 36 C 47 39.866 43.866 43 40 43 Z" />
|
||||
<ellipse cx="40" cy="40" rx="27" ry="10" fill="none" stroke="#ffffff" stroke-width="2.5" transform="rotate(-25 40 40)" />
|
||||
<circle cx="15.5" cy="51.5" r="3.5" fill="#ffffff" />
|
||||
</g>
|
||||
<text x="85" y="59" class="text">SpatialHub</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 824 B |
17
frontend/public/assets/images/spatialhub-logo.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg viewBox="0 0 450 80" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text {
|
||||
font-family: 'Ubuntu', 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 58px;
|
||||
font-weight: 600;
|
||||
fill: #009688;
|
||||
}
|
||||
</style>
|
||||
<circle cx="40" cy="40" r="35" fill="#009688" />
|
||||
<g fill="#ffffff">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M 40 18 C 30 18 22 26 22 36 C 22 48 40 63 40 63 C 40 63 58 48 58 36 C 58 26 50 18 40 18 Z M 40 43 C 36.134 43 33 39.866 33 36 C 33 32.134 36.134 29 40 29 C 43.866 29 47 32.134 47 36 C 47 39.866 43.866 43 40 43 Z" />
|
||||
<ellipse cx="40" cy="40" rx="27" ry="10" fill="none" stroke="#ffffff" stroke-width="2.5" transform="rotate(-25 40 40)" />
|
||||
<circle cx="15.5" cy="51.5" r="3.5" fill="#ffffff" />
|
||||
</g>
|
||||
<text x="85" y="59" class="text">SpatialHub</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 824 B |
@@ -196,6 +196,131 @@ export const ItemsPublicSchema = {
|
||||
title: 'ItemsPublic'
|
||||
} as const;
|
||||
|
||||
export const LocationCreateSchema = {
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string',
|
||||
maxLength: 255,
|
||||
minLength: 1,
|
||||
title: 'Title'
|
||||
},
|
||||
description: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string',
|
||||
maxLength: 255
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Description'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['title'],
|
||||
title: 'LocationCreate'
|
||||
} as const;
|
||||
|
||||
export const LocationPublicSchema = {
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string',
|
||||
maxLength: 255,
|
||||
minLength: 1,
|
||||
title: 'Title'
|
||||
},
|
||||
description: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string',
|
||||
maxLength: 255
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Description'
|
||||
},
|
||||
id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
title: 'Id'
|
||||
},
|
||||
owner_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
title: 'Owner Id'
|
||||
},
|
||||
created_at: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string',
|
||||
format: 'date-time'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Created At'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['title', 'id', 'owner_id'],
|
||||
title: 'LocationPublic'
|
||||
} as const;
|
||||
|
||||
export const LocationUpdateSchema = {
|
||||
properties: {
|
||||
title: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string',
|
||||
maxLength: 255,
|
||||
minLength: 1
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Title'
|
||||
},
|
||||
description: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string',
|
||||
maxLength: 255
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Description'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
title: 'LocationUpdate'
|
||||
} as const;
|
||||
|
||||
export const LocationsPublicSchema = {
|
||||
properties: {
|
||||
data: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/LocationPublic'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Data'
|
||||
},
|
||||
count: {
|
||||
type: 'integer',
|
||||
title: 'Count'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['data', 'count'],
|
||||
title: 'LocationsPublic'
|
||||
} as const;
|
||||
|
||||
export const MessageSchema = {
|
||||
properties: {
|
||||
message: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { CancelablePromise } from './core/CancelablePromise';
|
||||
import { OpenAPI } from './core/OpenAPI';
|
||||
import { request as __request } from './core/request';
|
||||
import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||
import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||
|
||||
export class ItemsService {
|
||||
/**
|
||||
@@ -116,6 +116,117 @@ export class ItemsService {
|
||||
}
|
||||
}
|
||||
|
||||
export class LocationsService {
|
||||
/**
|
||||
* Read Locations
|
||||
* Retrieve locations.
|
||||
* @param data The data for the request.
|
||||
* @param data.skip
|
||||
* @param data.limit
|
||||
* @returns LocationsPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readLocations(data: LocationsReadLocationsData = {}): CancelablePromise<LocationsReadLocationsResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/locations/',
|
||||
query: {
|
||||
skip: data.skip,
|
||||
limit: data.limit
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Location
|
||||
* Create new location.
|
||||
* @param data The data for the request.
|
||||
* @param data.requestBody
|
||||
* @returns LocationPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static createLocation(data: LocationsCreateLocationData): CancelablePromise<LocationsCreateLocationResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/locations/',
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Location
|
||||
* Get location by ID.
|
||||
* @param data The data for the request.
|
||||
* @param data.id
|
||||
* @returns LocationPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readLocation(data: LocationsReadLocationData): CancelablePromise<LocationsReadLocationResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/locations/{id}',
|
||||
path: {
|
||||
id: data.id
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Location
|
||||
* Update a location.
|
||||
* @param data The data for the request.
|
||||
* @param data.id
|
||||
* @param data.requestBody
|
||||
* @returns LocationPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static updateLocation(data: LocationsUpdateLocationData): CancelablePromise<LocationsUpdateLocationResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'PUT',
|
||||
url: '/api/v1/locations/{id}',
|
||||
path: {
|
||||
id: data.id
|
||||
},
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Location
|
||||
* Delete a location.
|
||||
* @param data The data for the request.
|
||||
* @param data.id
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static deleteLocation(data: LocationsDeleteLocationData): CancelablePromise<LocationsDeleteLocationResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/locations/{id}',
|
||||
path: {
|
||||
id: data.id
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class LoginService {
|
||||
/**
|
||||
* Login Access Token
|
||||
|
||||
@@ -36,6 +36,29 @@ export type ItemUpdate = {
|
||||
description?: (string | null);
|
||||
};
|
||||
|
||||
export type LocationCreate = {
|
||||
title: string;
|
||||
description?: (string | null);
|
||||
};
|
||||
|
||||
export type LocationPublic = {
|
||||
title: string;
|
||||
description?: (string | null);
|
||||
id: string;
|
||||
owner_id: string;
|
||||
created_at?: (string | null);
|
||||
};
|
||||
|
||||
export type LocationsPublic = {
|
||||
data: Array<LocationPublic>;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type LocationUpdate = {
|
||||
title?: (string | null);
|
||||
description?: (string | null);
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
message: string;
|
||||
};
|
||||
@@ -145,6 +168,38 @@ export type ItemsDeleteItemData = {
|
||||
|
||||
export type ItemsDeleteItemResponse = (Message);
|
||||
|
||||
export type LocationsReadLocationsData = {
|
||||
limit?: number;
|
||||
skip?: number;
|
||||
};
|
||||
|
||||
export type LocationsReadLocationsResponse = (LocationsPublic);
|
||||
|
||||
export type LocationsCreateLocationData = {
|
||||
requestBody: LocationCreate;
|
||||
};
|
||||
|
||||
export type LocationsCreateLocationResponse = (LocationPublic);
|
||||
|
||||
export type LocationsReadLocationData = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type LocationsReadLocationResponse = (LocationPublic);
|
||||
|
||||
export type LocationsUpdateLocationData = {
|
||||
id: string;
|
||||
requestBody: LocationUpdate;
|
||||
};
|
||||
|
||||
export type LocationsUpdateLocationResponse = (LocationPublic);
|
||||
|
||||
export type LocationsDeleteLocationData = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type LocationsDeleteLocationResponse = (Message);
|
||||
|
||||
export type LoginLoginAccessTokenData = {
|
||||
formData: Body_login_login_access_token;
|
||||
};
|
||||
|
||||
@@ -4,13 +4,13 @@ import { FaXTwitter } from "react-icons/fa6"
|
||||
const socialLinks = [
|
||||
{
|
||||
icon: FaGithub,
|
||||
href: "https://github.com/fastapi/fastapi",
|
||||
href: "https://makefire.fun",
|
||||
label: "GitHub",
|
||||
},
|
||||
{ icon: FaXTwitter, href: "https://x.com/fastapi", label: "X" },
|
||||
{ icon: FaXTwitter, href: "https://makefire.fun", label: "X" },
|
||||
{
|
||||
icon: FaLinkedinIn,
|
||||
href: "https://linkedin.com/company/fastapi",
|
||||
href: "https://makefire.fun",
|
||||
label: "LinkedIn",
|
||||
},
|
||||
]
|
||||
@@ -22,7 +22,7 @@ export function Footer() {
|
||||
<footer className="border-t py-4 px-6">
|
||||
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Full Stack FastAPI Template - {currentYear}
|
||||
SpatialHub - {currentYear}
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{socialLinks.map(({ icon: Icon, href, label }) => (
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Link } from "@tanstack/react-router"
|
||||
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import icon from "/assets/images/fastapi-icon.svg"
|
||||
import iconLight from "/assets/images/fastapi-icon-light.svg"
|
||||
import logo from "/assets/images/fastapi-logo.svg"
|
||||
import logoLight from "/assets/images/fastapi-logo-light.svg"
|
||||
import icon from "/assets/images/spatialhub-icon.svg"
|
||||
import iconLight from "/assets/images/spatialhub-icon-light.svg"
|
||||
import logo from "/assets/images/spatialhub-logo.svg"
|
||||
import logoLight from "/assets/images/spatialhub-logo-light.svg"
|
||||
|
||||
interface LogoProps {
|
||||
variant?: "full" | "icon" | "responsive"
|
||||
@@ -29,7 +29,7 @@ export function Logo({
|
||||
<>
|
||||
<img
|
||||
src={fullLogo}
|
||||
alt="FastAPI"
|
||||
alt="SpatialHub"
|
||||
className={cn(
|
||||
"h-6 w-auto group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
@@ -37,7 +37,7 @@ export function Logo({
|
||||
/>
|
||||
<img
|
||||
src={iconLogo}
|
||||
alt="FastAPI"
|
||||
alt="SpatialHub"
|
||||
className={cn(
|
||||
"size-5 hidden group-data-[collapsible=icon]:block",
|
||||
className,
|
||||
@@ -47,7 +47,7 @@ export function Logo({
|
||||
) : (
|
||||
<img
|
||||
src={variant === "full" ? fullLogo : iconLogo}
|
||||
alt="FastAPI"
|
||||
alt="SpatialHub"
|
||||
className={cn(variant === "full" ? "h-6 w-auto" : "size-5", className)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import { type ItemCreate, ItemsService } from "@/client"
|
||||
import { type LocationCreate, LocationsService } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -37,7 +37,7 @@ const formSchema = z.object({
|
||||
|
||||
type FormData = z.infer<typeof formSchema>
|
||||
|
||||
const AddItem = () => {
|
||||
const AddLocation = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
@@ -53,16 +53,16 @@ const AddItem = () => {
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ItemCreate) =>
|
||||
ItemsService.createItem({ requestBody: data }),
|
||||
mutationFn: (data: LocationCreate) =>
|
||||
LocationsService.createLocation({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showSuccessToast("Item created successfully")
|
||||
showSuccessToast("Location created successfully")
|
||||
form.reset()
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["locations"] })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -75,14 +75,14 @@ const AddItem = () => {
|
||||
<DialogTrigger asChild>
|
||||
<Button className="my-4">
|
||||
<Plus className="mr-2" />
|
||||
Add Item
|
||||
Add Location
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Item</DialogTitle>
|
||||
<DialogTitle>Add Location</DialogTitle>
|
||||
<DialogDescription>
|
||||
Fill in the details to add a new item.
|
||||
Fill in the details to add a new location.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
@@ -141,4 +141,4 @@ const AddItem = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default AddItem
|
||||
export default AddLocation
|
||||
@@ -3,7 +3,7 @@ import { Trash2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import { ItemsService } from "@/client"
|
||||
import { LocationsService } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,25 +19,25 @@ import { LoadingButton } from "@/components/ui/loading-button"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
interface DeleteItemProps {
|
||||
interface DeleteLocationProps {
|
||||
id: string
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const DeleteItem = ({ id, onSuccess }: DeleteItemProps) => {
|
||||
const DeleteLocation = ({ id, onSuccess }: DeleteLocationProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
const { handleSubmit } = useForm()
|
||||
|
||||
const deleteItem = async (id: string) => {
|
||||
await ItemsService.deleteItem({ id: id })
|
||||
const deleteLocation = async (id: string) => {
|
||||
await LocationsService.deleteLocation({ id: id })
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: deleteItem,
|
||||
mutationFn: deleteLocation,
|
||||
onSuccess: () => {
|
||||
showSuccessToast("The item was deleted successfully")
|
||||
showSuccessToast("The location was deleted successfully")
|
||||
setIsOpen(false)
|
||||
onSuccess()
|
||||
},
|
||||
@@ -59,14 +59,14 @@ const DeleteItem = ({ id, onSuccess }: DeleteItemProps) => {
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete Item
|
||||
Delete Location
|
||||
</DropdownMenuItem>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Item</DialogTitle>
|
||||
<DialogTitle>Delete Location</DialogTitle>
|
||||
<DialogDescription>
|
||||
This item will be permanently deleted. Are you sure? You will not
|
||||
This location will be permanently deleted. Are you sure? You will not
|
||||
be able to undo this action.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -91,4 +91,4 @@ const DeleteItem = ({ id, onSuccess }: DeleteItemProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteItem
|
||||
export default DeleteLocation
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import { type ItemPublic, ItemsService } from "@/client"
|
||||
import { type LocationPublic, LocationsService } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -37,12 +37,12 @@ const formSchema = z.object({
|
||||
|
||||
type FormData = z.infer<typeof formSchema>
|
||||
|
||||
interface EditItemProps {
|
||||
item: ItemPublic
|
||||
interface EditLocationProps {
|
||||
location: LocationPublic
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const EditItem = ({ item, onSuccess }: EditItemProps) => {
|
||||
const EditLocation = ({ location, onSuccess }: EditLocationProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
@@ -52,22 +52,22 @@ const EditItem = ({ item, onSuccess }: EditItemProps) => {
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
title: item.title,
|
||||
description: item.description ?? undefined,
|
||||
title: location.title,
|
||||
description: location.description ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
ItemsService.updateItem({ id: item.id, requestBody: data }),
|
||||
LocationsService.updateLocation({ id: location.id, requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showSuccessToast("Item updated successfully")
|
||||
showSuccessToast("Location updated successfully")
|
||||
setIsOpen(false)
|
||||
onSuccess()
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["locations"] })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -82,15 +82,15 @@ const EditItem = ({ item, onSuccess }: EditItemProps) => {
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Pencil />
|
||||
Edit Item
|
||||
Edit Location
|
||||
</DropdownMenuItem>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Item</DialogTitle>
|
||||
<DialogTitle>Edit Location</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the item details below.
|
||||
Update the location details below.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
@@ -142,4 +142,4 @@ const EditItem = ({ item, onSuccess }: EditItemProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default EditItem
|
||||
export default EditLocation
|
||||
@@ -1,21 +1,21 @@
|
||||
import { EllipsisVertical } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import type { ItemPublic } from "@/client"
|
||||
import type { LocationPublic } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import DeleteItem from "../Items/DeleteItem"
|
||||
import EditItem from "../Items/EditItem"
|
||||
import DeleteLocation from "../Locations/DeleteLocation"
|
||||
import EditLocation from "../Locations/EditLocation"
|
||||
|
||||
interface ItemActionsMenuProps {
|
||||
item: ItemPublic
|
||||
interface LocationActionsMenuProps {
|
||||
location: LocationPublic
|
||||
}
|
||||
|
||||
export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => {
|
||||
export const LocationActionsMenu = ({ location }: LocationActionsMenuProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -26,8 +26,8 @@ export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<EditItem item={item} onSuccess={() => setOpen(false)} />
|
||||
<DeleteItem id={item.id} onSuccess={() => setOpen(false)} />
|
||||
<EditLocation location={location} onSuccess={() => setOpen(false)} />
|
||||
<DeleteLocation id={location.id} onSuccess={() => setOpen(false)} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { Check, Copy } from "lucide-react"
|
||||
|
||||
import type { ItemPublic } from "@/client"
|
||||
import type { LocationPublic } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ItemActionsMenu } from "./ItemActionsMenu"
|
||||
import { LocationActionsMenu } from "./LocationActionsMenu"
|
||||
|
||||
function CopyId({ id }: { id: string }) {
|
||||
const [copiedText, copy] = useCopyToClipboard()
|
||||
@@ -31,7 +31,7 @@ function CopyId({ id }: { id: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<ItemPublic>[] = [
|
||||
export const columns: ColumnDef<LocationPublic>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
@@ -66,7 +66,7 @@ export const columns: ColumnDef<ItemPublic>[] = [
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<ItemActionsMenu item={row.original} />
|
||||
<LocationActionsMenu location={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
const PendingItems = () => (
|
||||
const PendingSkeleton = () => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -43,4 +43,4 @@ const PendingItems = () => (
|
||||
</Table>
|
||||
)
|
||||
|
||||
export default PendingItems
|
||||
export default PendingSkeleton
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Briefcase, Home, Users } from "lucide-react"
|
||||
import { Home, MapPin, Users } from "lucide-react"
|
||||
|
||||
import { SidebarAppearance } from "@/components/Common/Appearance"
|
||||
import { Logo } from "@/components/Common/Logo"
|
||||
@@ -14,7 +14,7 @@ import { User } from "./User"
|
||||
|
||||
const baseItems: Item[] = [
|
||||
{ icon: Home, title: "Dashboard", path: "/" },
|
||||
{ icon: Briefcase, title: "Items", path: "/items" },
|
||||
{ icon: MapPin, title: "Locations", path: "/locations" },
|
||||
]
|
||||
|
||||
export function AppSidebar() {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as LayoutRouteImport } from './routes/_layout'
|
||||
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||
import { Route as LayoutItemsRouteImport } from './routes/_layout/items'
|
||||
import { Route as LayoutLocationsRouteImport } from './routes/_layout/locations'
|
||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||
|
||||
const SignupRoute = SignupRouteImport.update({
|
||||
@@ -53,9 +53,9 @@ const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
|
||||
path: '/settings',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
const LayoutItemsRoute = LayoutItemsRouteImport.update({
|
||||
id: '/items',
|
||||
path: '/items',
|
||||
const LayoutLocationsRoute = LayoutLocationsRouteImport.update({
|
||||
id: '/locations',
|
||||
path: '/locations',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
||||
@@ -65,14 +65,14 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof LayoutIndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/recover-password': typeof RecoverPasswordRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/admin': typeof LayoutAdminRoute
|
||||
'/items': typeof LayoutItemsRoute
|
||||
'/locations': typeof LayoutLocationsRoute
|
||||
'/settings': typeof LayoutSettingsRoute
|
||||
'/': typeof LayoutIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
@@ -80,7 +80,7 @@ export interface FileRoutesByTo {
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/admin': typeof LayoutAdminRoute
|
||||
'/items': typeof LayoutItemsRoute
|
||||
'/locations': typeof LayoutLocationsRoute
|
||||
'/settings': typeof LayoutSettingsRoute
|
||||
'/': typeof LayoutIndexRoute
|
||||
}
|
||||
@@ -92,21 +92,21 @@ export interface FileRoutesById {
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/_layout/admin': typeof LayoutAdminRoute
|
||||
'/_layout/items': typeof LayoutItemsRoute
|
||||
'/_layout/locations': typeof LayoutLocationsRoute
|
||||
'/_layout/settings': typeof LayoutSettingsRoute
|
||||
'/_layout/': typeof LayoutIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/recover-password'
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/admin'
|
||||
| '/items'
|
||||
| '/locations'
|
||||
| '/settings'
|
||||
| '/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/login'
|
||||
@@ -114,7 +114,7 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/admin'
|
||||
| '/items'
|
||||
| '/locations'
|
||||
| '/settings'
|
||||
| '/'
|
||||
id:
|
||||
@@ -125,7 +125,7 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/_layout/admin'
|
||||
| '/_layout/items'
|
||||
| '/_layout/locations'
|
||||
| '/_layout/settings'
|
||||
| '/_layout/'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -171,7 +171,7 @@ declare module '@tanstack/react-router' {
|
||||
'/_layout': {
|
||||
id: '/_layout'
|
||||
path: ''
|
||||
fullPath: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof LayoutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
@@ -189,11 +189,11 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LayoutSettingsRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
'/_layout/items': {
|
||||
id: '/_layout/items'
|
||||
path: '/items'
|
||||
fullPath: '/items'
|
||||
preLoaderRoute: typeof LayoutItemsRouteImport
|
||||
'/_layout/locations': {
|
||||
id: '/_layout/locations'
|
||||
path: '/locations'
|
||||
fullPath: '/locations'
|
||||
preLoaderRoute: typeof LayoutLocationsRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
'/_layout/admin': {
|
||||
@@ -208,14 +208,14 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
interface LayoutRouteChildren {
|
||||
LayoutAdminRoute: typeof LayoutAdminRoute
|
||||
LayoutItemsRoute: typeof LayoutItemsRoute
|
||||
LayoutLocationsRoute: typeof LayoutLocationsRoute
|
||||
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
||||
LayoutIndexRoute: typeof LayoutIndexRoute
|
||||
}
|
||||
|
||||
const LayoutRouteChildren: LayoutRouteChildren = {
|
||||
LayoutAdminRoute: LayoutAdminRoute,
|
||||
LayoutItemsRoute: LayoutItemsRoute,
|
||||
LayoutLocationsRoute: LayoutLocationsRoute,
|
||||
LayoutSettingsRoute: LayoutSettingsRoute,
|
||||
LayoutIndexRoute: LayoutIndexRoute,
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useSuspenseQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Search } from "lucide-react"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { ItemsService } from "@/client"
|
||||
import { DataTable } from "@/components/Common/DataTable"
|
||||
import AddItem from "@/components/Items/AddItem"
|
||||
import { columns } from "@/components/Items/columns"
|
||||
import PendingItems from "@/components/Pending/PendingItems"
|
||||
|
||||
function getItemsQueryOptions() {
|
||||
return {
|
||||
queryFn: () => ItemsService.readItems({ skip: 0, limit: 100 }),
|
||||
queryKey: ["items"],
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/_layout/items")({
|
||||
component: Items,
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: "Items - FastAPI Template",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
function ItemsTableContent() {
|
||||
const { data: items } = useSuspenseQuery(getItemsQueryOptions())
|
||||
|
||||
if (items.data.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-12">
|
||||
<div className="rounded-full bg-muted p-4 mb-4">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">You don't have any items yet</h3>
|
||||
<p className="text-muted-foreground">Add a new item to get started</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <DataTable columns={columns} data={items.data} />
|
||||
}
|
||||
|
||||
function ItemsTable() {
|
||||
return (
|
||||
<Suspense fallback={<PendingItems />}>
|
||||
<ItemsTableContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function Items() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Items</h1>
|
||||
<p className="text-muted-foreground">Create and manage your items</p>
|
||||
</div>
|
||||
<AddItem />
|
||||
</div>
|
||||
<ItemsTable />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
frontend/src/routes/_layout/locations.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useSuspenseQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Search } from "lucide-react"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { LocationsService } from "@/client"
|
||||
import { DataTable } from "@/components/Common/DataTable"
|
||||
import AddLocation from "@/components/Locations/AddLocation"
|
||||
import { columns } from "@/components/Locations/columns"
|
||||
import PendingSkeleton from "@/components/Pending/PendingSkeleton"
|
||||
|
||||
function getLocationsQueryOptions() {
|
||||
return {
|
||||
queryFn: () => LocationsService.readLocations({ skip: 0, limit: 100 }),
|
||||
queryKey: ["locations"],
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/_layout/locations")({
|
||||
component: Locations,
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: "Locations - FastAPI Template",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
function LocationsTableContent() {
|
||||
const { data: locations } = useSuspenseQuery(getLocationsQueryOptions())
|
||||
|
||||
if (locations.data.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-12">
|
||||
<div className="rounded-full bg-muted p-4 mb-4">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">You don't have any locations yet</h3>
|
||||
<p className="text-muted-foreground">Add a new location to get started</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <DataTable columns={columns} data={locations.data} />
|
||||
}
|
||||
|
||||
function LocationsTable() {
|
||||
return (
|
||||
<Suspense fallback={<PendingSkeleton />}>
|
||||
<LocationsTableContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function Locations() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Locations</h1>
|
||||
<p className="text-muted-foreground">Create and manage your locations</p>
|
||||
</div>
|
||||
<AddLocation />
|
||||
</div>
|
||||
<LocationsTable />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { createUser } from "./utils/privateApi"
|
||||
import {
|
||||
randomEmail,
|
||||
randomItemDescription,
|
||||
randomItemTitle,
|
||||
randomPassword,
|
||||
} from "./utils/random"
|
||||
import { logInUser } from "./utils/user"
|
||||
|
||||
test("Items page is accessible and shows correct title", async ({ page }) => {
|
||||
await page.goto("/items")
|
||||
await expect(page.getByRole("heading", { name: "Items" })).toBeVisible()
|
||||
await expect(page.getByText("Create and manage your items")).toBeVisible()
|
||||
})
|
||||
|
||||
test("Add Item button is visible", async ({ page }) => {
|
||||
await page.goto("/items")
|
||||
await expect(page.getByRole("button", { name: "Add Item" })).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe("Items management", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
let email: string
|
||||
const password = randomPassword()
|
||||
|
||||
test.beforeAll(async () => {
|
||||
email = randomEmail()
|
||||
await createUser({ email, password })
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await logInUser(page, email, password)
|
||||
await page.goto("/items")
|
||||
})
|
||||
|
||||
test("Create a new item successfully", async ({ page }) => {
|
||||
const title = randomItemTitle()
|
||||
const description = randomItemDescription()
|
||||
|
||||
await page.getByRole("button", { name: "Add Item" }).click()
|
||||
await page.getByLabel("Title").fill(title)
|
||||
await page.getByLabel("Description").fill(description)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
|
||||
await expect(page.getByText("Item created successfully")).toBeVisible()
|
||||
await expect(page.getByText(title)).toBeVisible()
|
||||
})
|
||||
|
||||
test("Create item with only required fields", async ({ page }) => {
|
||||
const title = randomItemTitle()
|
||||
|
||||
await page.getByRole("button", { name: "Add Item" }).click()
|
||||
await page.getByLabel("Title").fill(title)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
|
||||
await expect(page.getByText("Item created successfully")).toBeVisible()
|
||||
await expect(page.getByText(title)).toBeVisible()
|
||||
})
|
||||
|
||||
test("Cancel item creation", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Add Item" }).click()
|
||||
await page.getByLabel("Title").fill("Test Item")
|
||||
await page.getByRole("button", { name: "Cancel" }).click()
|
||||
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("Title is required", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Add Item" }).click()
|
||||
await page.getByLabel("Title").fill("")
|
||||
await page.getByLabel("Title").blur()
|
||||
|
||||
await expect(page.getByText("Title is required")).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe("Edit and Delete", () => {
|
||||
let itemTitle: string
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
itemTitle = randomItemTitle()
|
||||
|
||||
await page.getByRole("button", { name: "Add Item" }).click()
|
||||
await page.getByLabel("Title").fill(itemTitle)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
await expect(page.getByText("Item created successfully")).toBeVisible()
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("Edit an item successfully", async ({ page }) => {
|
||||
const itemRow = page.getByRole("row").filter({ hasText: itemTitle })
|
||||
await itemRow.getByRole("button").last().click()
|
||||
await page.getByRole("menuitem", { name: "Edit Item" }).click()
|
||||
|
||||
const updatedTitle = randomItemTitle()
|
||||
await page.getByLabel("Title").fill(updatedTitle)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
|
||||
await expect(page.getByText("Item updated successfully")).toBeVisible()
|
||||
await expect(page.getByText(updatedTitle)).toBeVisible()
|
||||
})
|
||||
|
||||
test("Delete an item successfully", async ({ page }) => {
|
||||
const itemRow = page.getByRole("row").filter({ hasText: itemTitle })
|
||||
await itemRow.getByRole("button").last().click()
|
||||
await page.getByRole("menuitem", { name: "Delete Item" }).click()
|
||||
|
||||
await page.getByRole("button", { name: "Delete" }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText("The item was deleted successfully"),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText(itemTitle)).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Items empty state", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
test("Shows empty state message when no items exist", async ({ page }) => {
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
await createUser({ email, password })
|
||||
await logInUser(page, email, password)
|
||||
|
||||
await page.goto("/items")
|
||||
|
||||
await expect(page.getByText("You don't have any items yet")).toBeVisible()
|
||||
await expect(page.getByText("Add a new item to get started")).toBeVisible()
|
||||
})
|
||||
})
|
||||
111
openresty-example.conf
Normal file
@@ -0,0 +1,111 @@
|
||||
# =============================================================================
|
||||
# OpenResty / Nginx 反向代理配置示例
|
||||
# 此文件仅供参考,实际配置在 1Panel 网站管理中完成
|
||||
# =============================================================================
|
||||
#
|
||||
# 在 1Panel 中需要创建 2 个网站:
|
||||
# 1. makefire.fun → 前端
|
||||
# 2. api.makefire.fun → 后端 API
|
||||
#
|
||||
# 两个网站都需要:
|
||||
# - 开启 SSL (1Panel 可自动申请 Let's Encrypt 证书)
|
||||
# - 开启 HTTP → HTTPS 强制跳转
|
||||
#
|
||||
# 以下是每个网站的反向代理配置内容:
|
||||
|
||||
# =============================================
|
||||
# 网站 1: makefire.fun (前端)
|
||||
# =============================================
|
||||
# 在 1Panel 中: 网站 → 创建网站 → 反向代理
|
||||
# 代理地址: http://127.0.0.1:3001
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name makefire.fun;
|
||||
|
||||
# SSL 证书 (由 1Panel 自动管理,以下路径仅为示例)
|
||||
# ssl_certificate /path/to/cert.pem;
|
||||
# ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
# HTTP → HTTPS 跳转
|
||||
if ($scheme = http) {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# 前端反向代理
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SPA 路由支持 — 当后端 nginx 返回 404 时,由前端处理
|
||||
proxy_intercept_errors on;
|
||||
error_page 404 = /index.html;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_set_header Host $host;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Gzip 压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
}
|
||||
|
||||
# =============================================
|
||||
# 网站 2: api.makefire.fun (后端 API)
|
||||
# =============================================
|
||||
# 在 1Panel 中: 网站 → 创建网站 → 反向代理
|
||||
# 代理地址: http://127.0.0.1:8000
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name api.makefire.fun;
|
||||
|
||||
# SSL 证书 (由 1Panel 自动管理)
|
||||
# ssl_certificate /path/to/cert.pem;
|
||||
# ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
# HTTP → HTTPS 跳转
|
||||
if ($scheme = http) {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# API 反向代理
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket 支持 (如后续需要)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# 请求体大小限制 (文件上传等)
|
||||
client_max_body_size 10m;
|
||||
}
|
||||
|
||||
# Gzip 压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
}
|
||||
@@ -17,6 +17,12 @@
|
||||
|
||||
### Internal
|
||||
|
||||
* ⬆ Bump actions/download-artifact from 7 to 8. PR [#2208](https://github.com/fastapi/full-stack-fastapi-template/pull/2208) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump actions/upload-artifact from 6 to 7. PR [#2207](https://github.com/fastapi/full-stack-fastapi-template/pull/2207) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump @tanstack/react-router from 1.157.3 to 1.163.3. PR [#2215](https://github.com/fastapi/full-stack-fastapi-template/pull/2215) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump @tanstack/react-router-devtools from 1.159.10 to 1.163.3. PR [#2212](https://github.com/fastapi/full-stack-fastapi-template/pull/2212) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump @tanstack/react-query from 5.90.20 to 5.90.21. PR [#2213](https://github.com/fastapi/full-stack-fastapi-template/pull/2213) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump @types/node from 25.1.0 to 25.3.2. PR [#2214](https://github.com/fastapi/full-stack-fastapi-template/pull/2214) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump tailwindcss from 4.1.18 to 4.2.0. PR [#2198](https://github.com/fastapi/full-stack-fastapi-template/pull/2198) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump axios from 1.13.4 to 1.13.5. PR [#2199](https://github.com/fastapi/full-stack-fastapi-template/pull/2199) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump @vitejs/plugin-react-swc from 4.2.2 to 4.2.3. PR [#2200](https://github.com/fastapi/full-stack-fastapi-template/pull/2200) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
|
||||