diff --git a/backend/.gitignore b/backend/.gitignore
index df7c370..07ddde0 100644
--- a/backend/.gitignore
+++ b/backend/.gitignore
@@ -3,6 +3,10 @@ build/
dist/
.venv
+# for localdev
+/localdata/*
+!localdata/
+
.vscode/
*.swp
*.swo
diff --git a/backend/Makefile b/backend/Makefile
index 6648023..c5e2cec 100644
--- a/backend/Makefile
+++ b/backend/Makefile
@@ -7,9 +7,12 @@ db-migrate:
db-upgrade:
alembic upgrade head
+start:
+ uv run uvicorn main:app --reload
+
spec:
mkdir -p dist/
python3 -m app.scripts.save_openapi
generate-fixtures:
- python3 -m app.scripts.fixtures
\ No newline at end of file
+ python3 -m app.scripts.fixtures
diff --git a/backend/app/app.py b/backend/app/app.py
index 108c2b8..9d27e68 100644
--- a/backend/app/app.py
+++ b/backend/app/app.py
@@ -5,6 +5,7 @@
from app.db import engine, sync_db_connection_context, db
from fastapi.middleware.cors import CORSMiddleware
from app.inventory.routes import router as inventory_router
+from app.files.routes import router as files_router
from app.extensions.all_models import *
app = FastAPI()
@@ -31,6 +32,7 @@ async def db_session_middleware(request: Request, call_next):
return response
app.include_router(inventory_router)
+app.include_router(files_router)
class UserCreateSchema(BaseModel):
@@ -52,4 +54,4 @@ async def create_user(body: UserCreateSchema) -> UserDumpSchema:
return UserDumpSchema(
id=user.id,
username=user.username,
- )
\ No newline at end of file
+ )
diff --git a/backend/app/files/models.py b/backend/app/files/models.py
index a2cb65f..49999f9 100644
--- a/backend/app/files/models.py
+++ b/backend/app/files/models.py
@@ -1,11 +1,14 @@
from app.db.base import Base
from sqlalchemy.orm import Mapped, mapped_column
-from sqlalchemy import Integer, Text, Numeric
+from sqlalchemy import Integer, Text
+
class File(Base):
__tablename__ = "files"
- id: Mapped[int] = mapped_column(
- Integer,
- primary_key=True
- )
\ No newline at end of file
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+
+ url: Mapped[str] = mapped_column(Text)
+
+ filename: Mapped[str] = mapped_column(Text)
+ content_type: Mapped[str] = mapped_column(Text)
diff --git a/backend/app/files/routes.py b/backend/app/files/routes.py
new file mode 100644
index 0000000..1a68538
--- /dev/null
+++ b/backend/app/files/routes.py
@@ -0,0 +1,64 @@
+from app.files.models import File
+from app.db import db
+from sqlalchemy import insert
+from uuid import uuid4
+from fastapi import APIRouter, UploadFile, File as FastAPIFile
+from app.files.schemas import FileDumpSchema
+from app.utils.strings import make_slug
+from app.utils.db_helpers import exec_scalar
+
+router = APIRouter(
+ prefix="/file",
+ responses={404: {"description": "Not found"}},
+)
+
+
+@router.post("/upload/")
+async def upload_single_file(file: UploadFile = FastAPIFile(...)) -> FileDumpSchema:
+ satis_file = handle_file_upload(
+ contents=await file.read(),
+ filename=file.filename,
+ content_type=file.content_type,
+ )
+ return FileDumpSchema(
+ id=satis_file.id,
+ url=satis_file.url,
+ filename=satis_file.filename,
+ )
+
+
+def save_file(
+ contents: bytes,
+ filename: str,
+) -> str:
+ url = f"localdata/{make_slug()}-{filename}"
+ with open(url, "wb") as f:
+ f.write(contents)
+
+ return url
+
+
+def handle_file_upload(
+ contents: bytes, filename: str | None, content_type: str | None
+) -> File:
+ resolved_filename = filename if filename is not None else f"{make_slug()}-upload"
+ file_url = save_file(
+ contents=contents,
+ filename=resolved_filename,
+ )
+
+ file = exec_scalar(
+ insert(File)
+ .values(
+ [
+ {
+ "url": file_url,
+ "filename": resolved_filename,
+ "content_type": content_type or "UNKNOWN",
+ }
+ ]
+ )
+ .returning(File)
+ )
+
+ return file
diff --git a/backend/app/files/schemas.py b/backend/app/files/schemas.py
new file mode 100644
index 0000000..c5b686f
--- /dev/null
+++ b/backend/app/files/schemas.py
@@ -0,0 +1,4 @@
+from pydantic import BaseModel
+
+class FileDumpSchema(BaseModel):
+ id: int
diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/utils/db_helpers.py b/backend/app/utils/db_helpers.py
new file mode 100644
index 0000000..70548c6
--- /dev/null
+++ b/backend/app/utils/db_helpers.py
@@ -0,0 +1,11 @@
+from sqlalchemy.sql.dml import ReturningInsert
+from sqlalchemy import Select
+from app.db import db
+
+
+def exec_scalars[T](statement: Select[tuple[T]] | ReturningInsert[tuple[T]]) -> list[T]:
+ return list(db.execute(statement).scalars().all())
+
+
+def exec_scalar[T](statement: Select[tuple[T]] | ReturningInsert[tuple[T]]) -> T:
+ return db.execute(statement).scalars().one()
diff --git a/backend/app/utils/strings.py b/backend/app/utils/strings.py
new file mode 100644
index 0000000..af13394
--- /dev/null
+++ b/backend/app/utils/strings.py
@@ -0,0 +1,6 @@
+from uuid import uuid4
+
+
+def make_slug(length: int | None = None):
+ resolved_length = length if length is not None else 6
+ return uuid4().hex[:resolved_length]
diff --git a/backend/migrations/versions/11c780da6347_.py b/backend/migrations/versions/11c780da6347_.py
new file mode 100644
index 0000000..1d53a8b
--- /dev/null
+++ b/backend/migrations/versions/11c780da6347_.py
@@ -0,0 +1,32 @@
+"""empty message
+
+Revision ID: 11c780da6347
+Revises: e284a1681385
+Create Date: 2026-07-19 03:53:26.383878
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '11c780da6347'
+down_revision: Union[str, Sequence[str], None] = 'e284a1681385'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('files', sa.Column('url', sa.Text(), nullable=False))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('files', 'url')
+ # ### end Alembic commands ###
diff --git a/backend/migrations/versions/af7ae6368a55_.py b/backend/migrations/versions/af7ae6368a55_.py
new file mode 100644
index 0000000..1d16391
--- /dev/null
+++ b/backend/migrations/versions/af7ae6368a55_.py
@@ -0,0 +1,34 @@
+"""empty message
+
+Revision ID: af7ae6368a55
+Revises: 11c780da6347
+Create Date: 2026-07-19 04:20:09.409024
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'af7ae6368a55'
+down_revision: Union[str, Sequence[str], None] = '11c780da6347'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('files', sa.Column('filename', sa.Text(), nullable=False))
+ op.add_column('files', sa.Column('content_type', sa.Text(), nullable=False))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('files', 'content_type')
+ op.drop_column('files', 'filename')
+ # ### end Alembic commands ###
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 351ecd1..b9c7de4 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -3,7 +3,7 @@ name = "backend"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
-requires-python = ">=3.11"
+requires-python = ">=3.14"
dependencies = [
"alembic>=1.18.5",
"fastapi[standard]>=0.139.0",
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b57712d..23f794a 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@mantine/core": "^9.4.1",
+ "@mantine/dropzone": "^9.4.1",
"@mantine/hooks": "^9.4.1",
"@tabler/icons-react": "^3.45.0",
"@tanstack/react-query": "^5.101.2",
@@ -621,6 +622,21 @@
"react-dom": "^19.2.0"
}
},
+ "node_modules/@mantine/dropzone": {
+ "version": "9.4.1",
+ "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-9.4.1.tgz",
+ "integrity": "sha512-QmtwDsViRmIdiwpc3d1loV3ryb8L6O4y3aiuMnAcLxL31a9hUPlHUZAs6KSuA+XbziZDG4BjxRVYjB70PxwORg==",
+ "license": "MIT",
+ "dependencies": {
+ "react-dropzone": "15.0.0"
+ },
+ "peerDependencies": {
+ "@mantine/core": "9.4.1",
+ "@mantine/hooks": "9.4.1",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ }
+ },
"node_modules/@mantine/hooks": {
"version": "9.4.1",
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.4.1.tgz",
@@ -752,9 +768,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -772,9 +785,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -792,9 +802,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -812,9 +819,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -832,9 +836,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -852,9 +853,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1364,6 +1362,15 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/attr-accept": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
+ "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -1801,6 +1808,18 @@
"node": ">=16.0.0"
}
},
+ "node_modules/file-selector": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
+ "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.7.0"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -1970,7 +1989,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/jsesc": {
@@ -2187,9 +2205,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2211,9 +2226,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2235,9 +2247,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2259,9 +2268,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2333,6 +2339,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2402,6 +2420,15 @@
"node": ">=18"
}
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/openapi-fetch": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz",
@@ -2559,6 +2586,17 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -2590,6 +2628,29 @@
"react": "^19.2.7"
}
},
+ "node_modules/react-dropzone": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-15.0.0.tgz",
+ "integrity": "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "attr-accept": "^2.2.4",
+ "file-selector": "^2.1.0",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">= 10.13"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8 || 18.0.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/react-number-format": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 396ccdf..e0e2449 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,6 +11,7 @@
},
"dependencies": {
"@mantine/core": "^9.4.1",
+ "@mantine/dropzone": "^9.4.1",
"@mantine/hooks": "^9.4.1",
"@tabler/icons-react": "^3.45.0",
"@tanstack/react-query": "^5.101.2",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 48ef856..3f05611 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,20 +1,69 @@
import { useState } from 'react'
import { client } from './api/client'
import '@mantine/core/styles.css'
-import { Table, MantineProvider, Stack, Button, Anchor, Container, Group, TextInput } from '@mantine/core'
-import { IconSearch } from '@tabler/icons-react';
+import '@mantine/dropzone/styles.css'
+import { Table, MantineProvider, Stack, Button, Anchor, Container, Group, Text, TextInput } from '@mantine/core'
+import { IconSearch, IconUpload, IconX, IconPhoto } from '@tabler/icons-react';
import classes from './FooterSimple.module.css';
import './App.css'
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import sns_logo from "./assets/sns_logo.png"
+import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone';
import type { components } from './api/schema'
type Asset = components['schemas']['AssetDumpSchema']
+type SatisDropzoneProps = {
+}
+
+function SatisDropzone({...props}: SatisDropzoneProps) {
+ const {mutate} = client.useMutation('post', '/file/upload/')
+ return (
+