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 ( + { + const blob = new Blob([await files[0].bytes()], { type: files[0].type }); + + const formData = new FormData(); + formData.append('file', blob, files[0].name); + mutate({ body: formData}) + + console.log('accepted files', files) + } + + } + onReject={(files) => console.log('rejected files', files)} + maxSize={5 * 1024 ** 2} + accept={IMAGE_MIME_TYPE} + {...props} + > + + + + + + + + + + + +
+ + Drag images here or click to select files + + + Attach as many files as you like, each file should not exceed 5mb + +
+
+
+ ); +} + function InventoryPublic() { const [search, setSearch] = useState('') - const { data: assets } = client.useQuery('get', `/inventory/asset/?search=${search}`) + const { data: assets } = client.useQuery('get', '/inventory/asset/?search=') console.log(search) if (!assets) return
Loading...
@@ -162,10 +211,12 @@ export default function App() { gap: "30px" }}> + + ) -} \ No newline at end of file +} diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index e9e8138..d0487a5 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -56,6 +56,23 @@ export interface paths { patch?: never; trace?: never; }; + "/file/upload/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Upload Single File */ + post: operations["upload_single_file_file_upload__post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/": { parameters: { query?: never; @@ -135,6 +152,16 @@ export interface components { /** Id */ id: number; }; + /** Body_upload_single_file_file_upload__post */ + Body_upload_single_file_file_upload__post: { + /** File */ + file: string; + }; + /** FileDumpSchema */ + FileDumpSchema: { + /** Id */ + id: number; + }; /** HTTPValidationError */ HTTPValidationError: { /** Detail */ @@ -354,6 +381,46 @@ export interface operations { }; }; }; + upload_single_file_file_upload__post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_upload_single_file_file_upload__post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FileDumpSchema"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; create_user__post: { parameters: { query?: never;