Initial commit on BE

This commit is contained in:
DmsAnhr 2026-01-27 09:11:58 +07:00
commit 04e1199ee9
138 changed files with 12014 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

14
.env.example Normal file
View File

@ -0,0 +1,14 @@
DEBUG=1
HOST=127.0.0.1
PORT=5000
DATABASE_URL=postgresql+asyncpg://satupeta_user:satupeta!QAZ2wsx@localhost:5432/satupeta
SECRET_KEY=tgAjg3fj4y4DtPCuIOlUH8cWFSxx9VZqvbXFsOiMnsmo2FF6NU
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440
REFRESH_TOKEN_EXPIRE_DAYS=7
MINIO_ENDPOINT_URL=localhost:9000
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin123
GEONETWORK_API_URL=https://geonetwork.jatimprov.go.id/geonetwork/srv/api/search/records/_search

174
.gitignore vendored Normal file
View File

@ -0,0 +1,174 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc

82
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,82 @@
repos:
# Formatting tools
- repo: https://github.com/psf/black
rev: 24.1.1
hooks:
- id: black
args:
- "--line-length=119"
- "--include=\\.pyi?$"
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
args:
- --profile=black
- repo: https://github.com/myint/autoflake
rev: v2.2.1
hooks:
- id: autoflake
args: [ --in-place, --remove-unused-variables, --remove-all-unused-imports ]
files: \.py$
- repo: https://github.com/asottile/pyupgrade
rev: v3.15.0
hooks:
- id: pyupgrade
args: [ --py38-plus ]
# Code quality and linting
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: fix-encoding-pragma
args: [ --remove ]
- id: check-yaml
- id: debug-statements
- repo: https://github.com/pre-commit/pygrep-hooks
rev: v1.10.0
hooks:
- id: python-use-type-annotations
# Dependency management
- repo: https://github.com/peterdemin/pip-compile-multi
rev: v2.6.2
hooks:
- id: pip-compile-multi-verify
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v2.5.0
hooks:
- id: setup-cfg-fmt
args: [ "--max-py-version=3.12" ]
# Documentation and commit checks
- repo: local
hooks:
- id: rst-lint
name: rst
entry: rst-lint --encoding utf-8
files: ^(RELEASING.rst|README.rst|TIDELIFT.rst)$
language: python
- repo: https://github.com/commitizen-tools/commitizen
rev: v3.13.0
hooks:
- id: commitizen
stages: [ commit-msg ]
# Custom checks
- repo: local
hooks:
- id: pytest-staged
name: test on Staged
entry: sh -c 'pytest $(git diff --name-only --cached | grep -E "\\.py$") || exit 0'
stages: [ pre-commit ]
language: python

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.13.0

67
Dockerfile Normal file
View File

@ -0,0 +1,67 @@
FROM python:3.13-slim-bookworm AS base
ENV POETRY_HOME="/opt/poetry" \
PYTHONPATH=/app \
PYTHONHASHSEED=0 \
POETRY_VERSION=1.7.1 \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=false \
POETRY_VIRTUALENVS_IN_PROJECT=false \
PYTHONWRITEBYTECODE=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/poetry/bin:$PATH"
WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apt \
apt-get update && \
apt-get install -y --no-install-recommends \
libpq-dev \
locales \
locales-all \
libmagic1 \
libjemalloc2 \
procps && \
rm -rf /var/lib/apt/lists/* && \
echo "id_ID.UTF-8 UTF-8" > /etc/locale.gen && \
locale-gen
ENV LD_PRELOAD="/usr/lib/x86_64-linux-gnu/libjemalloc.so.2"
ENV MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:30000,muzzy_decay_ms:30000"
FROM base AS builder
RUN --mount=type=cache,target=/var/cache/apt \
apt-get update && \
apt-get install -y --no-install-recommends \
curl \
git \
build-essential && \
rm -rf /var/lib/apt/lists/* && \
curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python3 -
COPY pyproject.toml poetry.lock ./
RUN --mount=type=cache,target=/root/.cache/pypoetry \
poetry install --no-root --no-interaction --no-ansi
RUN apt-get autoremove -y && \
apt-get purge -y curl git build-essential && \
apt-get clean -y && \
rm -rf /root/.cache /var/lib/apt/lists/*
FROM base AS app-image
COPY --from=builder /opt/poetry /opt/poetry
COPY --from=builder /usr/local/lib/python3.13/site-packages/ /usr/local/lib/python3.13/site-packages/
COPY --from=builder /usr/local/bin/ /usr/local/bin/
COPY . /app
ENV PYTHONFAULTHANDLER=1 \
PYTHONHASHSEED=random \
PYTHONOPTIMIZE=2
EXPOSE 5000
CMD ["python", "-OO", "run.py"]

393
README.md Normal file
View File

@ -0,0 +1,393 @@
# Portal Satu Peta Backend
Backend for the Portal Satu Peta application.
## Folder Structure
```
├── .env.example # Example environment file
├── .github/ # GitHub Actions configuration
│ └── workflows/
│ └── deploy.yml # Workflow for deployment
├── .gitignore # Files and folders ignored by Git
├── .pre-commit-config.yaml # Pre-commit hooks configuration
├── Dockerfile # Instructions for building the Docker image
├── README.md # This file
├── alembic.ini # Alembic configuration for database migrations
├── app/ # Main application directory
│ ├── __init__.py
│ ├── api/ # API module (endpoints)
│ │ ├── dependencies/ # Dependencies for API (e.g., authentication)
│ │ └── v1/ # API version 1
│ │ ├── __init__.py
│ │ └── routes/ # Route/endpoint definitions
│ ├── core/ # Core application configuration
│ │ ├── __init__.py
│ │ ├── config.py # Application settings (from environment variables)
│ │ ├── data_types.py # Custom data types
│ │ ├── database.py # Database configuration
│ │ ├── exceptions.py # Custom exceptions
│ │ ├── minio_client.py # Client for MinIO (object storage)
│ │ ├── params.py # Common parameters for requests
│ │ ├── responses.py # Standard response schemas
│ │ └── security.py # Security-related functions (password hashing, tokens)
│ ├── main.py # FastAPI application entry point
│ ├── models/ # SQLAlchemy model definitions (database tables)
│ │ ├── __init__.py
│ │ ├── base.py # Base model for SQLAlchemy
│ │ └── ... (other models)
│ ├── repositories/ # Data access logic (interaction with the database)
│ │ ├── __init__.py
│ │ ├── base.py # Base repository
│ │ └── ... (other repositories)
│ ├── schemas/ # Pydantic schemas (request/response data validation)
│ │ ├── __init__.py
│ │ ├── base.py # Base schema
│ │ └── ... (other schemas)
│ ├── services/ # Application business logic
│ │ ├── __init__.py
│ │ ├── base.py # Base service
│ │ └── ... (other services)
│ └── utils/ # General utilities
│ ├── __init__.py
│ ├── encryption.py # Encryption functions
│ ├── helpers.py # Helper functions
│ └── system.py # System-related utilities
├── assets/ # Static asset files (if any)
├── docker-compose.yml # Docker Compose configuration
├── migrations/ # Alembic database migration scripts
│ ├── README
│ ├── env.py
│ ├── script.py.mako
│ ├── scripts.py
│ └── versions/ # Migration version files
│ └── __init__.py
├── poetry.lock # Poetry dependency lock file
├── pyproject.toml # Poetry project configuration file
├── run.py # Script to run Uvicorn server locally
└── tests/ # Directory for unit and integration tests
├── __init__.py
├── conftest.py # Pytest configuration
├── test_api/
│ └── __init__.py
└── test_services/
└── __init__.py
```
## How to Run the Project
### 1. Initial Setup
* Ensure you have Python (version >=3.10, <4.0 recommended as per `pyproject.toml`) and Poetry installed.
* Copy the `.env.example` file to `.env` and customize its configuration, especially for database and MinIO connections.
```bash
cp .env.example .env
```
* Edit the `.env` file as needed.
### 2. Running Locally (using Poetry and Uvicorn)
1. **Install dependencies:**
```bash
poetry install
```
2. **Run database migrations (if necessary):**
Ensure the database is running and the configuration in `.env` is correct.
```bash
poetry run alembic upgrade head
```
Alternatively, if there's a custom script for migrations as seen in `deploy.yml`:
```bash
poetry run python migrations/scripts.py
```
*(Check the content of `migrations/scripts.py` for the exact command if it differs)*
3. **Run the application server:**
```bash
poetry run python run.py
```
Or directly using Uvicorn:
```bash
poetry run uvicorn app.main:app --host 0.0.0.0 --port 5000 --reload
```
The application will run at `http://localhost:5000` (or as configured in `.env` and `run.py`).
### 3. Running Using Docker
1. **Ensure Docker and Docker Compose are installed.**
2. **Build and run the container:**
From the project root directory, run:
```bash
docker-compose up --build
```
If you have an `environment.env` file (as referenced in `docker-compose.yml`), ensure it exists and contains the necessary environment configurations. Otherwise, you might need to adjust `docker-compose.yml` to use the `.env` file or set environment variables directly.
The application will run at `http://localhost:5000` (as per port mapping in `docker-compose.yml`).
## How to Create an Endpoint, Model, Repository, and Service
This project follows a layered architecture pattern commonly used in FastAPI applications.
### 1. Creating a Model (`app/models/`)
Models represent tables in your database. They are defined using SQLAlchemy.
Example (e.g., `app/models/item_model.py`):
```python
from sqlalchemy import Column, Integer, String, ForeignKey, UUID
from sqlalchemy.orm import relationship
import uuid6
from . import Base # Ensure Base is imported from app.models
class ItemModel(Base):
__tablename__ = "items"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
name = Column(String, index=True)
description = Column(String, index=True)
owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id")) # Example relationship
owner = relationship("UserModel", back_populates="items") # Adjust to your User model
```
* Don't forget to add the new model to `app/models/__init__.py` if necessary and create a database migration using Alembic.
```bash
poetry run alembic revision -m "create_items_table"
```
Then edit the newly created migration file in `migrations/versions/` to define the `upgrade()` and `downgrade()` functions, and run:
```bash
poetry run alembic upgrade head
```
### 2. Creating a Schema (`app/schemas/`)
Pydantic schemas are used for request data validation and response data formatting.
Example (e.g., `app/schemas/item_schema.py`):
```python
from pydantic import BaseModel
from app.core.data_types import UUID7Field # Or the appropriate UUID type
from typing import Optional
class ItemBase(BaseModel):
name: str
description: Optional[str] = None
class ItemCreateSchema(ItemBase):
pass
class ItemUpdateSchema(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class ItemSchema(ItemBase):
id: UUID7Field
owner_id: UUID7Field
class Config:
orm_mode = True # or from_attributes = True for Pydantic v2
```
### 3. Creating a Repository (`app/repositories/`)
Repositories are responsible for all database interactions related to a model.
Example (e.g., `app/repositories/item_repository.py`):
```python
from sqlalchemy import select
from fastapi_async_sqlalchemy import db # or the appropriate db session
from app.models.item_model import ItemModel # Import your model
from .base import BaseRepository # Import BaseRepository
from app.core.data_types import UUID7Field
class ItemRepository(BaseRepository[ItemModel]):
def __init__(self):
super().__init__(ItemModel)
async def find_by_name(self, name: str) -> ItemModel | None:
query = select(self.model).filter(self.model.name == name)
result = await db.session.execute(query)
return result.scalar_one_or_none()
# Add other methods as needed (findById, create, update, delete, etc.)
# Example find_by_id from UserRepository:
async def find_by_id(self, id: UUID7Field) -> ItemModel | None:
query = select(self.model).filter(self.model.id == id)
result = await db.session.execute(query)
return result.scalar_one_or_none()
```
* Ensure to register the new repository in `app/api/dependencies/factory.py` if you are using the factory pattern for dependencies.
### 4. Creating a Service (`app/services/`)
Services contain the application's business logic. Services will use repositories to interact with data.
Example (e.g., `app/services/item_service.py`):
```python
from typing import Dict, List, Tuple, Union
from uuid6 import UUID # or from app.core.data_types import UUID7Field
from fastapi import HTTPException, status
from app.models.item_model import ItemModel
from app.repositories.item_repository import ItemRepository
from app.schemas.item_schema import ItemCreateSchema, ItemUpdateSchema, ItemSchema # Import your schemas
from app.schemas.user_schema import UserSchema # For user info performing the action
from .base import BaseService
from app.core.exceptions import NotFoundException
class ItemService(BaseService[ItemModel, ItemRepository]):
def __init__(self, repository: ItemRepository):
super().__init__(ItemModel, repository)
# self.user_service = user_service # If other services are needed
async def create_item(self, item_data: ItemCreateSchema, current_user: UserSchema) -> ItemModel:
# Business logic before creating the item
# For example, check if an item with the same name already exists
existing_item = await self.repository.find_by_name(item_data.name)
if existing_item:
raise HTTPException(status_code=400, detail="Item with this name already exists")
item_dict = item_data.model_dump()
item_dict['owner_id'] = current_user.id # Example of setting the owner
return await self.repository.create(item_dict)
async def get_item_by_id(self, item_id: UUID, current_user: UserSchema) -> ItemModel:
item = await self.repository.find_by_id(item_id)
if not item:
raise NotFoundException(f"Item with id {item_id} not found")
# Business logic for authorization, for example:
# if item.owner_id != current_user.id and not current_user.is_admin:
# raise HTTPException(status_code=403, detail="Not authorized to access this item")
return item
# Add other methods (update, delete, get_all, etc.)
```
* Ensure to register the new service in `app/api/dependencies/factory.py`.
### 5. Creating an Endpoint (`app/api/v1/routes/`)
Endpoints are the HTTP entry points to your application. They are defined using FastAPI APIRouter.
Example (e.g., `app/api/v1/routes/item_route.py`):
```python
from typing import List
from fastapi import APIRouter, Depends, status
from app.api.dependencies.auth import get_current_active_user # Authentication dependency
from app.api.dependencies.factory import Factory # Factory for service dependencies
from app.core.data_types import UUID7Field
from app.schemas.item_schema import ItemCreateSchema, ItemSchema, ItemUpdateSchema # Your schemas
from app.schemas.user_schema import UserSchema # User schema for auth dependency
from app.services.item_service import ItemService # Your service
from app.schemas.base import PaginatedResponse # If using pagination
from app.core.params import CommonParams # If using common parameters
router = APIRouter()
@router.post("/items", response_model=ItemSchema, status_code=status.HTTP_201_CREATED)
async def create_item(
item_in: ItemCreateSchema,
current_user: UserSchema = Depends(get_current_active_user),
service: ItemService = Depends(Factory().get_item_service), # Get service from factory
):
item = await service.create_item(item_data=item_in, current_user=current_user)
return item
@router.get("/items/{item_id}", response_model=ItemSchema)
async def read_item(
item_id: UUID7Field,
current_user: UserSchema = Depends(get_current_active_user),
service: ItemService = Depends(Factory().get_item_service),
):
item = await service.get_item_by_id(item_id=item_id, current_user=current_user)
return item
# Add other endpoints (GET all, PUT/PATCH, DELETE)
# Example GET all with pagination:
@router.get("/items", response_model=PaginatedResponse[ItemSchema])
async def get_items(
params: CommonParams = Depends(),
user_active: UserSchema = Depends(get_current_active_user),
service: ItemService = Depends(Factory().get_item_service),
):
# Assume your service has a find_all method similar to UserService
items, total = await service.find_all(
filters=params.filter,
sort=params.sort,
search=params.search,
limit=params.limit,
offset=params.offset,
user=user_active, # For access control if needed
)
return PaginatedResponse(
items=[ItemSchema.model_validate(item) for item in items],
total=total,
limit=params.limit,
offset=params.offset,
has_more=total > (offset + params.limit),
)
```
* Register the new router in `app/api/v1/__init__.py` or `app/main.py`.
Example in `app/main.py`:
```python
// ... existing code ...
from app.api.v1.routes import item_route # Import your new router
// ... existing code ...
app.include_router(item_route.router, prefix="/api/v1", tags=["Items"])
// ... existing code ...
```
## How to Deploy
This project is configured to be deployed using GitHub Actions when there is a push to the `main` branch or via manual trigger.
The deployment process defined in <mcfile path="/.github/workflows/deploy.yml" name="deploy.yml"></mcfile> is as follows:
1. **Checkout Code**: The code from the repository is fetched.
2. **Set up Docker Buildx**: Prepares the environment for building Docker images.
3. **Build and Export Docker image**: The Docker image `portal-satu-peta-backend:latest` is built and exported as a `.tar` file.
* Uses cache from GitHub Actions (GHA) to speed up the build process.
4. **Copy Docker image to server via SCP**: The `portal-satu-peta-backend.tar` file is copied to the target server (defined by secrets `SSH_HOST`, `SSH_USER`, `SSH_PORT`, `SSH_PASSWORD`).
5. **Deploy container**: An SSH script is executed on the target server:
* **Load image**: The Docker image from the `.tar` file is loaded into Docker on the server.
* **Stop and Remove Old Container**: The old container named `portal-satu-peta-backend` is stopped and removed (if it exists).
* **Run New Container**: A new container is run from the newly loaded image:
* Container name: `portal-satu-peta-backend`
* Restart policy: `unless-stopped`
* Environment file: `/home/application/.env` (ensure this file exists and is configured on the server)
* Port mapping: `5000:5000` (host port 5000 to container port 5000)
* Health check configuration.
* **Run Migrations**: The command `docker exec portal-satu-peta-backend python migrations/scripts.py` is executed inside the newly running container to perform database migrations.
* **Prune Old Images**: Old, unused Docker images (older than 24 hours) are removed to save disk space.
* **Clean Up**: The copied `.tar` file is removed from the server.
* **Verify Status**: The status of the `portal-satu-peta-backend` container is verified.
### Server Requirements for Deployment:
* Linux server with SSH access.
* Docker installed on the server.
* An environment file (e.g., `/home/application/.env`) must exist on the server and contain the correct configurations for production (database, secret keys, etc.).
* The SSH user used must have permissions to run Docker commands and access the necessary paths.
### GitHub Secrets Configuration:
Ensure the following secrets are configured in your GitHub repository (Settings > Secrets and variables > Actions):
* `SSH_HOST`: IP address or hostname of the deployment server.
* `SSH_USER`: Username for SSH login to the server.
* `SSH_PORT`: SSH server port (usually 22).
* `SSH_PASSWORD`: Password for the SSH user. (Using SSH keys is highly recommended for security).
# local run
- pyenv local 3.13.0
- poetry env use python
- poetry install
- poetry install --no-root
# run
- poetry run uvicorn app.main:app --reload

106
alembic.ini Normal file
View File

@ -0,0 +1,106 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = ${DATABASE_URL}
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

0
app/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,100 @@
from datetime import datetime
from typing import Optional
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from fastapi.security.utils import get_authorization_scheme_param
from jose import JWTError
from pydantic import ValidationError
from pytz import timezone
from app.api.dependencies.factory import Factory
from app.core.config import settings
from app.core.security import decode_token
from app.models import UserModel
from app.schemas.token_schema import TokenPayload
from app.schemas.user_schema import UserSchema
from app.services import UserService
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme), user_service: UserService = Depends(Factory().get_user_service)
) -> UserModel:
"""Validate token and return current user."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
token_data = TokenPayload(**payload)
if token_data.type != "access":
raise credentials_exception
user_id: Optional[str] = token_data.sub
if user_id is None:
raise credentials_exception
except (JWTError, ValidationError):
raise credentials_exception
user = await user_service.find_by_id(user_id)
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user")
return user
async def get_payload(request: Request, user_service: UserService = Depends(Factory().get_user_service)):
authorization: str = request.headers.get("Authorization")
if not authorization:
return None
scheme, token = get_authorization_scheme_param(authorization)
if scheme.lower() != "bearer" or not token:
return None
try:
payload = decode_token(token)
token_data = TokenPayload(**payload)
user_id: Optional[str] = token_data.sub
if user_id is None:
return None
user = await user_service.find_by_id(user_id)
if user is None:
return None
return user
except (JWTError, ValidationError):
return None
async def get_current_active_user(current_user: UserModel = Depends(get_current_user)) -> UserModel:
"""Check if current user is active."""
if not current_user.is_active or current_user.is_deleted:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user")
return current_user
async def get_current_active_admin(current_user: UserSchema = Depends(get_current_active_user)) -> UserModel:
"""Check if current admin is active."""
if current_user.role is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized")
if current_user.role.name != "administrator":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Your role does not have access to this resource"
)
return current_user

View File

View File

@ -0,0 +1,165 @@
from functools import partial
from app.core.minio_client import MinioClient
from app.models import (
CategoryModel,
ClassificationModel,
CredentialModel,
FeedbackModel,
FileModel,
MapProjectionSystemModel,
MapsetHistoryModel,
MapsetModel,
MapSourceModel,
NewsModel,
OrganizationModel,
RefreshTokenModel,
RegionalModel,
RoleModel,
SourceUsageModel,
UserModel,
)
from app.repositories import (
CategoryRepository,
ClassificationRepository,
CredentialRepository,
FeedbackRepository,
FileRepository,
MapProjectionSystemRepository,
MapsetHistoryRepository,
MapsetRepository,
MapSourceRepository,
NewsRepository,
OrganizationRepository,
RegionalRepository,
RoleRepository,
SourceUsageRepository,
TokenRepository,
UserRepository,
)
from app.services import (
AuthService,
CategoryService,
CountService,
ClassificationService,
CredentialService,
FeedbackService,
FileService,
MapProjectionSystemService,
MapsetHistoryService,
MapsetService,
MapSourceService,
NewsService,
OrganizationService,
RegionalService,
RoleService,
UserService,
)
class Factory:
organization_repository = staticmethod(partial(OrganizationRepository, OrganizationModel, MapsetModel))
role_repository = staticmethod(partial(RoleRepository, RoleModel))
user_repository = staticmethod(partial(UserRepository, UserModel))
token_repository = staticmethod(partial(TokenRepository, RefreshTokenModel))
news_repository = staticmethod(partial(NewsRepository, NewsModel))
file_repository = staticmethod(partial(FileRepository, FileModel))
credential_repository = staticmethod(partial(CredentialRepository, CredentialModel))
map_source_repository = staticmethod(partial(MapSourceRepository, MapSourceModel))
map_projection_system_repository = staticmethod(partial(MapProjectionSystemRepository, MapProjectionSystemModel))
category_repository = staticmethod(partial(CategoryRepository, CategoryModel))
classification_repository = staticmethod(partial(ClassificationRepository, ClassificationModel))
regional_repository = staticmethod(partial(RegionalRepository, RegionalModel))
mapset_repository = staticmethod(partial(MapsetRepository, MapsetModel))
mapset_history_repository = staticmethod(partial(MapsetHistoryRepository, MapsetHistoryModel))
map_source_usage_repository = staticmethod(partial(SourceUsageRepository, SourceUsageModel))
feedback_repository = staticmethod(partial(FeedbackRepository, FeedbackModel))
def get_auth_service(
self,
):
return AuthService(
user_repository=self.user_repository(),
token_repository=self.token_repository(),
)
def get_organization_service(
self,
):
return OrganizationService(self.organization_repository())
def get_role_service(
self,
):
return RoleService(self.role_repository())
def get_user_service(
self,
):
return UserService(self.user_repository(), self.role_repository())
def get_news_service(
self,
):
return NewsService(self.news_repository())
def get_file_service(
self,
):
return FileService(self.file_repository(), MinioClient())
def get_credential_service(
self,
):
return CredentialService(self.credential_repository())
def get_map_source_service(
self,
):
return MapSourceService(self.map_source_repository())
def get_map_projection_system_service(
self,
):
return MapProjectionSystemService(self.map_projection_system_repository())
def get_category_service(
self,
):
return CategoryService(self.category_repository())
def get_classification_service(
self,
):
return ClassificationService(self.classification_repository())
def get_regional_service(
self,
):
return RegionalService(self.regional_repository())
def get_mapset_service(
self,
):
return MapsetService(
self.mapset_repository(),
self.mapset_history_repository(),
self.map_source_usage_repository(),
self.get_file_service(),
)
def get_mapset_history_service(
self,
):
return MapsetHistoryService(self.mapset_history_repository())
def get_feedback_service(
self,
):
return FeedbackService(self.feedback_repository())
def get_count_service(
self,
):
# No repository dependencies needed for aggregated counts for now
return CountService()

40
app/api/v1/__init__.py Normal file
View File

@ -0,0 +1,40 @@
from fastapi import APIRouter
from app.api.v1.routes import (
auth_router,
category_router,
count_router,
classification_router,
credential_router,
feedback_router,
file_router,
geonetwork_router,
map_projection_system_router,
map_source_router,
mapset_history_router,
mapset_router,
news_router,
organization_router,
regional_router,
role_router,
user_router,
)
router = APIRouter()
router.include_router(auth_router, tags=["Auth"])
router.include_router(category_router, tags=["Categories"])
router.include_router(classification_router, tags=["Classifications"])
router.include_router(credential_router, tags=["Credentials"])
router.include_router(feedback_router, tags=["Feedback"])
router.include_router(file_router, tags=["Files"])
router.include_router(geonetwork_router, tags=["GeoNetwork"])
router.include_router(count_router, tags=["Counts"])
router.include_router(organization_router, tags=["Organizations"])
router.include_router(map_source_router, tags=["Map Sources"])
router.include_router(map_projection_system_router, tags=["Map Projection Systems"])
router.include_router(mapset_router, tags=["Mapsets"])
router.include_router(mapset_history_router, tags=["Mapset Histories"])
router.include_router(news_router, tags=["News"])
router.include_router(regional_router, tags=["Regionals"])
router.include_router(role_router, tags=["Roles"])
router.include_router(user_router, tags=["Users"])

View File

@ -0,0 +1,37 @@
from .auth_route import router as auth_router
from .category_route import router as category_router
from .classification_route import router as classification_router
from .credential_route import router as credential_router
from .feedback_route import router as feedback_router
from .file_route import router as file_router
from .count_route import router as count_router
from .geonetwork_route import router as geonetwork_router
from .map_projection_system_route import router as map_projection_system_router
from .map_source_route import router as map_source_router
from .mapset_history_route import router as mapset_history_router
from .mapset_route import router as mapset_router
from .news_route import router as news_router
from .organization_route import router as organization_router
from .regional_route import router as regional_router
from .role_route import router as role_router
from .user_route import router as user_router
__all__ = [
"organization_router",
"role_router",
"user_router",
"auth_router",
"news_router",
"file_router",
"credential_router",
"map_source_router",
"map_projection_system_router",
"category_router",
"regional_router",
"mapset_router",
"classification_router",
"mapset_history_router",
"feedback_router",
"geonetwork_router",
"count_router",
]

View File

@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.factory import Factory
from app.schemas.token_schema import RefreshTokenSchema, Token
from app.schemas.user_schema import UserSchema
from app.services.auth_service import AuthService
router = APIRouter()
@router.post("/auth/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(), auth_service: AuthService = Depends(Factory().get_auth_service)
):
user = await auth_service.authenticate_user(form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
return await auth_service.create_tokens(user.id)
@router.post("/auth/logout")
async def logout(
current_user: UserSchema = Depends(get_current_active_user),
auth_service: AuthService = Depends(Factory().get_auth_service),
):
await auth_service.logout(str(current_user.id))
@router.post("/auth/refresh", response_model=Token)
async def refresh_token(
refresh_token: RefreshTokenSchema, auth_service: AuthService = Depends(Factory().get_auth_service)
):
return await auth_service.refresh_token(refresh_token.refresh_token)
@router.get("/me", response_model=UserSchema)
async def read_users_me(current_user: UserSchema = Depends(get_current_active_user)):
return current_user

View File

@ -0,0 +1,72 @@
from fastapi import APIRouter, Depends, status
from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.factory import Factory
from app.core.data_types import UUID7Field
from app.core.params import CommonParams
from app.schemas.base import PaginatedResponse
from app.schemas.category_schema import (
CategoryCreateSchema,
CategorySchema,
CategoryUpdateSchema,
)
from app.services import CategoryService
router = APIRouter()
@router.get("/categories", response_model=PaginatedResponse[CategorySchema])
async def get_categorys(
params: CommonParams = Depends(), service: CategoryService = Depends(Factory().get_category_service)
):
filter = params.filter
sort = params.sort
search = params.search
group_by = params.group_by
limit = params.limit
offset = params.offset
categorys, total = await service.find_all(filter, sort, search, group_by, limit, offset)
return PaginatedResponse(
items=[CategorySchema.model_validate(category) for category in categorys],
total=total,
limit=limit,
offset=offset,
has_more=total > (offset + limit),
)
@router.get("/categories/{id}", response_model=CategorySchema)
async def get_category(id: UUID7Field, service: CategoryService = Depends(Factory().get_category_service)):
category = await service.find_by_id(id)
return category
@router.post(
"/categories",
response_model=CategorySchema,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_active_user)],
)
async def create_category(
data: CategoryCreateSchema, service: CategoryService = Depends(Factory().get_category_service)
):
category = await service.create(data.dict())
return category
@router.patch("/categories/{id}", response_model=CategorySchema, dependencies=[Depends(get_current_active_user)])
async def update_category(
id: UUID7Field,
data: CategoryUpdateSchema,
service: CategoryService = Depends(Factory().get_category_service),
):
category = await service.update(id, data.dict(exclude_unset=True))
return category
@router.delete(
"/categories/{id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_active_user)]
)
async def delete_category(id: UUID7Field, service: CategoryService = Depends(Factory().get_category_service)):
await service.delete(id)

View File

@ -0,0 +1,78 @@
from fastapi import APIRouter, Depends, status
from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.factory import Factory
from app.core.data_types import UUID7Field
from app.core.params import CommonParams
from app.schemas import (
ClassificationCreateSchema,
ClassificationSchema,
ClassificationUpdateSchema,
)
from app.schemas.base import PaginatedResponse
from app.services import ClassificationService
router = APIRouter()
@router.get("/classifications", response_model=PaginatedResponse[ClassificationSchema])
async def get_classifications(
params: CommonParams = Depends(), service: ClassificationService = Depends(Factory().get_classification_service)
):
filter = params.filter
sort = params.sort
search = params.search
group_by = params.group_by
limit = params.limit
offset = params.offset
classifications, total = await service.find_all(filter, sort, search, group_by, limit, offset)
return PaginatedResponse(
items=[ClassificationSchema.model_validate(classification) for classification in classifications],
total=total,
limit=limit,
offset=offset,
has_more=total > (offset + limit),
)
@router.get("/classifications/{id}", response_model=ClassificationSchema)
async def get_classification(
id: UUID7Field, service: ClassificationService = Depends(Factory().get_classification_service)
):
classification = await service.find_by_id(id)
return classification
@router.post(
"/classifications",
response_model=ClassificationSchema,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_active_user)],
)
async def create_classification(
data: ClassificationCreateSchema, service: ClassificationService = Depends(Factory().get_classification_service)
):
classification = await service.create(data.dict())
return classification
@router.patch(
"/classifications/{id}", response_model=ClassificationSchema, dependencies=[Depends(get_current_active_user)]
)
async def update_classification(
id: UUID7Field,
data: ClassificationUpdateSchema,
service: ClassificationService = Depends(Factory().get_classification_service),
):
classification = await service.update(id, data.dict(exclude_unset=True))
return classification
@router.delete(
"/classifications/{id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_active_user)]
)
async def delete_classification(
id: UUID7Field, service: ClassificationService = Depends(Factory().get_classification_service)
):
await service.delete(id)

View File

@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends
from app.api.dependencies.factory import Factory
from app.schemas.count_schema import CountSchema
from app.services.count_service import CountService
router = APIRouter()
@router.get("/count", response_model=CountSchema)
async def get_counts(service: CountService = Depends(Factory().get_count_service)):
data = await service.get_counts()
return CountSchema(**data)

View File

@ -0,0 +1,221 @@
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Path, Query, status
from app.api.dependencies.auth import get_current_active_admin, get_current_active_user
from app.api.dependencies.factory import Factory
from app.core.params import CommonParams
from app.schemas.base import PaginatedResponse
from app.schemas.credential_schema import (
CredentialCreateSchema,
CredentialSchema,
CredentialUpdateSchema,
CredentialWithSensitiveDataSchema,
)
from app.schemas.user_schema import UserSchema
from app.services import CredentialService
router = APIRouter()
@router.post(
"/credentials",
summary="Buat kredensial baru",
status_code=status.HTTP_201_CREATED,
)
async def create_credential(
data: CredentialCreateSchema,
current_user: UserSchema = Depends(get_current_active_admin),
service: CredentialService = Depends(Factory().get_credential_service),
):
"""
Buat kredensial baru dengan data yang terenkripsi.
Endpoint ini hanya dapat diakses oleh admin.
**Data sensitive yang didukung:**
- Database:
- host, port, username, password, database_name
- MinIO:
- endpoint, access_key, secret_key, secure, bucket_name
- API:
- base_url, api_key
- SSH:
- host, port, username, password (atau private_key)
- SMTP:
- host, port, username, password, use_tls
- FTP:
- host, port, username, password
"""
result = await service.create_credential(
name=data.name,
credential_type=data.credential_type,
sensitive_data=data.sensitive_data,
credential_metadata=data.credential_metadata,
description=data.description,
is_default=data.is_default,
user_id=current_user.id,
)
return result
@router.get(
"/credentials",
response_model=PaginatedResponse[CredentialSchema],
summary="Dapatkan daftar kredensial",
dependencies=[Depends(get_current_active_user)],
)
async def get_credentials(
credential_type: Optional[str] = Query(None, description="Filter berdasarkan tipe kredensial"),
include_inactive: bool = Query(False, description="Sertakan kredensial yang tidak aktif"),
params: CommonParams = Depends(),
service: CredentialService = Depends(Factory().get_credential_service),
):
"""
Dapatkan daftar kredensial dengan filtering and pagination.
"""
filter_params = params.filter or []
sort = params.sort
search = params.search
group_by = params.group_by
limit = params.limit
offset = params.offset
if credential_type:
filter_params.append(f"credential_type={credential_type}")
if not include_inactive:
filter_params.append(f"is_active=true")
credentials, total = await service.find_all(
filters=filter_params, sort=sort, search=search, group_by=group_by, limit=limit, offset=offset
)
return PaginatedResponse(
items=[credential for credential in credentials],
total=total,
limit=limit,
offset=offset,
has_more=offset + limit < total,
)
@router.get(
"/credentials/{credential_id}",
response_model=CredentialSchema,
summary="Dapatkan kredensial dengan data terdekripsi",
dependencies=[Depends(get_current_active_user)],
)
async def get_credential(
credential_id: UUID = Path(..., description="ID kredensial"),
service: CredentialService = Depends(Factory().get_credential_service),
):
"""
Dapatkan kredensial dengan data sensitif yang sudah didekripsi.
Endpoint ini hanya dapat diakses oleh admin.
"""
credential = await service.find_by_id(credential_id)
return credential
@router.get(
"/credentials/{credential_id}/decrypted",
response_model=CredentialWithSensitiveDataSchema,
summary="Dapatkan kredensial dengan data terdekripsi",
dependencies=[Depends(get_current_active_user)],
)
async def get_credential_decrypted(
credential_id: UUID = Path(..., description="ID kredensial"),
service: CredentialService = Depends(Factory().get_credential_service),
):
"""
Dapatkan kredensial dengan data sensitif yang sudah didekripsi.
Endpoint ini hanya dapat diakses oleh admin.
"""
credential = await service.get_credential_with_decrypted_data(credential_id)
return credential
@router.patch(
"/credentials/{credential_id}",
summary="Update kredensial",
)
async def update_credential(
credential_id: UUID,
data: CredentialUpdateSchema,
current_user: UserSchema = Depends(get_current_active_admin),
service: CredentialService = Depends(Factory().get_credential_service),
):
"""
Update kredensial.
Data sensitif bisa diupdate secara parsial. Field yang tidak disebutkan
dalam data.sensitive_data tidak akan diubah.
Endpoint ini hanya dapat diakses oleh admin.
"""
updated = await service.update_credential(credential_id, data.dict(exclude_unset=True), current_user.id)
return updated
@router.delete("/credentials/{credential_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_credential(
credential_id: UUID,
user: UserSchema = Depends(get_current_active_admin),
service: CredentialService = Depends(Factory().get_credential_service),
):
await service.delete(user, credential_id)
# @router.post(
# "/{credential_id}/test",
# response_model=CredentialTestResult,
# summary="Test koneksi menggunakan kredensial"
# )
# async def test_credential(
# credential_id: UUID,
# current_user: UserSchema = Depends(get_current_active_user),
# service: CredentialService = Depends(Factory().get_credential_service)
# ):
# """
# Test koneksi menggunakan kredensial.
# Hasil test berisi flag sukses dan detail tambahan.
# """
# result = await service.test_credential(
# service.db, credential_id, current_user.id
# )
# return result
# @router.delete(
# "/{credential_id}",
# status_code=status.HTTP_204_NO_CONTENT,
# summary="Hapus kredensial"
# )
# async def delete_credential(
# credential_id: UUID,
# current_user: UserSchema = Depends(get_current_admin_user),
# service: CredentialService = Depends(Factory().get_credential_service)
# ):
# """
# Hapus kredensial secara permanen.
# Endpoint ini hanya dapat diakses oleh admin.
# """
# credential = await service.find_by_id(service.db, credential_id)
# if not credential:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail="Credential not found"
# )
# await service.delete(service.db, credential_id)
# return None

View File

@ -0,0 +1,78 @@
from typing import List
from fastapi import APIRouter, Body, Depends, status
from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.factory import Factory
from app.core.data_types import UUID7Field
from app.core.params import CommonParams
from app.schemas.base import PaginatedResponse
from app.schemas.feedback_schema import (
FeedbackCreateSchema,
FeedbackSchema,
FeedbackUpdateSchema,
)
from app.services import FeedbackService
router = APIRouter()
@router.get("/feedback", response_model=PaginatedResponse[FeedbackSchema])
async def get_feedbacks(
params: CommonParams = Depends(),
service: FeedbackService = Depends(Factory().get_feedback_service),
user=Depends(get_current_active_user)
):
filter = params.filter
sort = params.sort
search = params.search
group_by = params.group_by
limit = params.limit
offset = params.offset
feedbacks, total = await service.find_all(filter, sort, search, group_by, limit, offset)
return PaginatedResponse(
items=[FeedbackSchema.model_validate(feedback) for feedback in feedbacks],
total=total,
limit=limit,
offset=offset,
has_more=total > (offset + limit),
)
@router.get("/feedback/{id}", response_model=FeedbackSchema)
async def get_feedback(
id: int,
service: FeedbackService = Depends(Factory().get_feedback_service),
user=Depends(get_current_active_user)
):
feedback = await service.find_by_id(id)
return feedback
@router.post("/feedback", response_model=FeedbackSchema, status_code=status.HTTP_201_CREATED)
async def create_feedback(
data: FeedbackCreateSchema, service: FeedbackService = Depends(Factory().get_feedback_service)
):
feedback = await service.create(data.dict())
return feedback
@router.patch("/feedback/{id}", response_model=FeedbackSchema)
async def update_feedback(
id: int,
data: FeedbackUpdateSchema,
service: FeedbackService = Depends(Factory().get_feedback_service),
user=Depends(get_current_active_user)
):
feedback = await service.update(id, data.dict(exclude_unset=True))
return feedback
@router.delete("/feedback/{id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_feedback(
id: int,
service: FeedbackService = Depends(Factory().get_feedback_service),
user=Depends(get_current_active_user)
):
await service.delete(id)

View File

@ -0,0 +1,98 @@
from typing import Optional
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Response,
UploadFile,
status,
)
from fastapi.responses import StreamingResponse
from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.factory import Factory
from app.core.data_types import UUID7Field
from app.core.params import CommonParams
from app.models import UserModel
from app.schemas.base import PaginatedResponse
from app.schemas.file_schema import FileSchema
from app.services import FileService
router = APIRouter()
@router.get("/files", response_model=PaginatedResponse[FileSchema], dependencies=[Depends(get_current_active_user)])
async def get_files(params: CommonParams = Depends(), service: FileService = Depends(Factory().get_file_service)):
filter = params.filter
sort = params.sort
search = params.search
group_by = params.group_by
limit = params.limit
offset = params.offset
files, total = await service.find_all(filter, sort, search, group_by, limit, offset)
return PaginatedResponse(
items=[FileSchema.model_validate(file) for file in files],
total=total,
limit=limit,
offset=offset,
has_more=total > (offset + limit),
)
@router.get("/files/{id}", response_model=FileSchema)
async def get_file(id: UUID7Field, service: FileService = Depends(Factory().get_file_service)):
file = await service.find_by_id(id)
return file
@router.post("/files", response_model=FileSchema, status_code=status.HTTP_201_CREATED)
async def upload_file(
file: UploadFile = File(...),
description: Optional[str] = Form(None),
current_user: UserModel = Depends(get_current_active_user),
service: FileService = Depends(Factory().get_file_service),
):
result = await service.upload_file(file=file, description=description, user_id=current_user.id)
return result
@router.get("/files/{file_id}", response_model=FileSchema, summary="Dapatkan metadata file")
async def get_file_info(file_id: UUID7Field, service: FileService = Depends(Factory().get_file_service)):
file = await service.find_by_id(file_id)
if not file:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File tidak ditemukan")
return file
@router.get("/files/{file_id}/download", summary="Download file")
async def download_file(file_id: UUID7Field, service: FileService = Depends(Factory().get_file_service)):
file_content, object_info, file_model = await service.get_file_content(file_id)
async def iterfile():
try:
chunk = await file_content.content.read(8192)
while chunk:
yield chunk
chunk = await file_content.content.read(8192)
finally:
await file_content.release()
return StreamingResponse(
iterfile(),
media_type=file_model.content_type,
headers={"Content-Disposition": f'attachment; filename="{file_model.filename}"'},
)
@router.delete("/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Hapus file")
async def delete_file(
file_id: UUID7Field,
current_user: UserModel = Depends(get_current_active_user),
service: FileService = Depends(Factory().get_file_service),
):
await service.delete_file_with_content(file_id, str(current_user.id))
return Response(status_code=status.HTTP_204_NO_CONTENT)

View File

@ -0,0 +1,65 @@
from fastapi import APIRouter, HTTPException, status
import httpx
router = APIRouter()
@router.get("/geonetwork-record")
async def get_geonetwork_record():
"""
Proxy endpoint untuk mengambil data dari GeoNetwork Jatim.
Melakukan request POST ke API GeoNetwork dengan query default.
"""
geonetwork_url = "https://geonetwork.jatimprov.go.id/geonetwork/srv/api/search/records/_search"
# Request body yang akan dikirim ke GeoNetwork API
request_body = {
"size": 0,
"track_total_hits": True,
"query": {
"bool": {
"must": {
"query_string": {
"query": "+isTemplate:n"
}
}
}
},
"aggs": {
"resourceType": {
"terms": {
"field": "resourceType",
"size": 10
}
}
}
}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
geonetwork_url,
json=request_body,
headers={
"Content-Type": "application/json",
"Accept": "application/json"
}
)
response.raise_for_status()
return response.json()
except httpx.TimeoutException:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail="Request to GeoNetwork API timed out"
)
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=e.response.status_code,
detail=f"GeoNetwork API returned error: {e.response.text}"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error connecting to GeoNetwork API: {str(e)}"
)

View File

@ -0,0 +1,87 @@
from fastapi import APIRouter, Depends, status
from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.factory import Factory
from app.core.data_types import UUID7Field
from app.core.params import CommonParams
from app.schemas.base import PaginatedResponse
from app.schemas.map_projection_system_schema import (
MapProjectionSystemCreateSchema,
MapProjectionSystemSchema,
MapProjectionSystemUpdateSchema,
)
from app.services import MapProjectionSystemService
router = APIRouter()
@router.get("/map_projection_systems", response_model=PaginatedResponse[MapProjectionSystemSchema])
async def get_map_projection_systems(
params: CommonParams = Depends(),
service: MapProjectionSystemService = Depends(Factory().get_map_projection_system_service),
):
filter = params.filter
sort = params.sort
search = params.search
group_by = params.group_by
limit = params.limit
offset = params.offset
map_projection_systems, total = await service.find_all(filter, sort, search, group_by, limit, offset)
return PaginatedResponse(
items=[
MapProjectionSystemSchema.model_validate(map_projection_system)
for map_projection_system in map_projection_systems
],
total=total,
limit=limit,
offset=offset,
has_more=total > (offset + limit),
)
@router.get("/map_projection_systems/{id}", response_model=MapProjectionSystemSchema)
async def get_map_projection_system(
id: UUID7Field, service: MapProjectionSystemService = Depends(Factory().get_map_projection_system_service)
):
map_projection_system = await service.find_by_id(id)
return map_projection_system
@router.post(
"/map_projection_systems",
response_model=MapProjectionSystemSchema,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_active_user)],
)
async def create_map_projection_system(
data: MapProjectionSystemCreateSchema,
service: MapProjectionSystemService = Depends(Factory().get_map_projection_system_service),
):
map_projection_system = await service.create(data.dict())
return map_projection_system
@router.patch(
"/map_projection_systems/{id}",
response_model=MapProjectionSystemSchema,
dependencies=[Depends(get_current_active_user)],
)
async def update_map_projection_system(
id: UUID7Field,
data: MapProjectionSystemUpdateSchema,
service: MapProjectionSystemService = Depends(Factory().get_map_projection_system_service),
):
map_projection_system = await service.update(id, data.dict(exclude_unset=True))
return map_projection_system
@router.delete(
"/map_projection_systems/{id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_active_user)],
)
async def delete_map_projection_system(
id: UUID7Field, service: MapProjectionSystemService = Depends(Factory().get_map_projection_system_service)
):
await service.delete(id)

View File

@ -0,0 +1,72 @@
from fastapi import APIRouter, Depends, status
from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.factory import Factory
from app.core.data_types import UUID7Field
from app.core.params import CommonParams
from app.schemas.base import PaginatedResponse
from app.schemas.map_source_schema import (
MapSourceCreateSchema,
MapSourceSchema,
MapSourceUpdateSchema,
)
from app.services import MapSourceService
router = APIRouter()
@router.get("/map_sources", response_model=PaginatedResponse[MapSourceSchema])
async def get_mapSources(
params: CommonParams = Depends(), service: MapSourceService = Depends(Factory().get_map_source_service)
):
filter = params.filter
sort = params.sort
search = params.search
group_by = params.group_by
limit = params.limit
offset = params.offset
mapSources, total = await service.find_all(filter, sort, search, group_by, limit, offset)
return PaginatedResponse(
items=[MapSourceSchema.model_validate(mapSource) for mapSource in mapSources],
total=total,
limit=limit,
offset=offset,
has_more=total > (offset + limit),
)
@router.get("/map_sources/{id}", response_model=MapSourceSchema)
async def get_mapSource(id: UUID7Field, service: MapSourceService = Depends(Factory().get_map_source_service)):
mapSource = await service.find_by_id(id)
return mapSource
@router.post(
"/map_sources",
response_model=MapSourceSchema,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_active_user)],
)
async def create_mapSource(
data: MapSourceCreateSchema, service: MapSourceService = Depends(Factory().get_map_source_service)
):
mapSource = await service.create(data.dict())
return mapSource
@router.patch("/map_sources/{id}", response_model=MapSourceSchema, dependencies=[Depends(get_current_active_user)])
async def update_mapSource(
id: UUID7Field,
data: MapSourceUpdateSchema,
service: MapSourceService = Depends(Factory().get_map_source_service),
):
mapSource = await service.update(id, data.dict(exclude_unset=True))
return mapSource
@router.delete(
"/map_sources/{id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_active_user)]
)
async def delete_mapSource(id: UUID7Field, service: MapSourceService = Depends(Factory().get_map_source_service)):
await service.delete(id)

View File

@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, status
from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.factory import Factory
from app.core.data_types import UUID7Field
from app.core.params import CommonParams
from app.schemas import MapsetHistoryCreateSchema, MapsetHistorySchema
from app.schemas.base import PaginatedResponse
from app.schemas.user_schema import UserSchema
from app.services import MapsetHistoryService
router = APIRouter()
@router.get(
"/histories",
response_model=PaginatedResponse[MapsetHistorySchema],
dependencies=[Depends(get_current_active_user)],
)
async def get_mapset_histories(
params: CommonParams = Depends(), service: MapsetHistoryService = Depends(Factory().get_mapset_history_service)
):
filter = params.filter
sort = params.sort
search = params.search
group_by = params.group_by
limit = params.limit
offset = params.offset
histories, total = await service.find_all(filter, sort, search, group_by, limit, offset)
return PaginatedResponse(
items=[MapsetHistorySchema.model_validate(history) for history in histories],
total=total,
limit=limit,
offset=offset,
has_more=total > (offset + limit),
)
@router.post("/histories", response_model=MapsetHistorySchema, status_code=status.HTTP_201_CREATED)
async def record_history(
data: MapsetHistoryCreateSchema,
user: UserSchema = Depends(get_current_active_user),
service: MapsetHistoryService = Depends(Factory().get_mapset_history_service),
):
return await service.create(user, data.dict())
@router.delete(
"/histories/{id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_active_user)]
)
async def delete_history(
id: UUID7Field, service: MapsetHistoryService = Depends(Factory().get_mapset_history_service)
):
await service.delete(id)

View File

@ -0,0 +1,136 @@
from typing import List
from fastapi import APIRouter, Body, Depends, status
from app.api.dependencies.auth import get_current_active_user, get_payload
from app.api.dependencies.factory import Factory
from app.core.data_types import UUID7Field
from app.core.params import CommonParams
from app.schemas.base import PaginatedResponse
from app.schemas.mapset_schema import (
MapsetByOrganizationSchema,
MapsetCreateSchema,
MapsetSchema,
MapsetUpdateSchema,
)
from app.schemas.user_schema import UserSchema
from app.services import MapsetService
router = APIRouter()
@router.get("/mapsets", response_model=PaginatedResponse[MapsetSchema])
async def get_mapsets(
params: CommonParams = Depends(),
user: UserSchema = Depends(get_payload),
service: MapsetService = Depends(Factory().get_mapset_service),
landing: bool = False,
):
filter = params.filter
sort = params.sort
search = params.search
group_by = params.group_by
limit = params.limit
offset = params.offset
mapsets, total = await service.find_all(user, filter, sort, search, group_by, limit, offset, landing)
return PaginatedResponse(
items=[MapsetSchema.model_validate(mapset) for mapset in mapsets],
total=total,
limit=limit,
offset=offset,
has_more=total > (offset + limit),
)
@router.get("/mapsets/organization", response_model=PaginatedResponse[MapsetByOrganizationSchema])
async def get_mapsets_organization(
params: CommonParams = Depends(),
user: UserSchema = Depends(get_payload),
service: MapsetService = Depends(Factory().get_mapset_service),
):
filter = params.filter
sort = params.sort
search = params.search
limit = params.limit
offset = params.offset
mapsets, total = await service.find_all_group_by_organization(user, filter, sort, search, limit, offset)
return PaginatedResponse(
items=[MapsetByOrganizationSchema.model_validate(mapset) for mapset in mapsets],
total=total,
limit=limit,
offset=offset,
has_more=total > (offset + limit),
)
@router.get("/mapsets/{id}", response_model=MapsetSchema)
async def get_mapset(
id: UUID7Field,
user: UserSchema = Depends(get_payload),
service: MapsetService = Depends(Factory().get_mapset_service),
):
mapset = await service.find_by_id(id, user=user)
return mapset
@router.post("/mapsets", response_model=MapsetSchema, status_code=status.HTTP_201_CREATED)
async def create_mapset(
data: MapsetCreateSchema,
user: UserSchema = Depends(get_current_active_user),
service: MapsetService = Depends(Factory().get_mapset_service),
):
mapset = await service.create(user, data.dict())
return mapset
@router.post("/mapsets/color_scale", status_code=status.HTTP_200_OK)
async def create_color_scale(
source_url: str = Body(..., embed=True),
color_range: list[str] = Body(None, embed=True),
boundary_file_id: UUID7Field = Body(None, embed=True),
service: MapsetService = Depends(Factory().get_mapset_service),
):
result, rangelist = await service.generate_colorscale(source_url, color_range, boundary_file_id)
return {"data": result, "rangelist": rangelist}
@router.patch(
"/mapsets/activation", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_active_user)]
)
async def update_mapset_activation(
ids: List[UUID7Field] = Body(...),
is_active: bool = Body(...),
service: MapsetService = Depends(Factory().get_mapset_service),
):
await service.bulk_update_activation(ids, is_active)
@router.patch("/mapsets/{id}", response_model=MapsetSchema, dependencies=[Depends(get_payload)])
async def update_mapset(
id: UUID7Field,
data: MapsetUpdateSchema,
user: UserSchema = Depends(get_current_active_user),
service: MapsetService = Depends(Factory().get_mapset_service),
):
mapset = await service.update(id, user, data.dict(exclude_unset=True))
return mapset
@router.delete(
"/mapsets/{id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_active_user)]
)
async def delete_mapset(id: UUID7Field, service: MapsetService = Depends(Factory().get_mapset_service)):
await service.delete(id)
@router.post("/mapsets/{id}/download", status_code=status.HTTP_204_NO_CONTENT)
async def increment_download_mapset(
id: UUID7Field,
service: MapsetService = Depends(Factory().get_mapset_service),
):
"""
Increment download_count for the given mapset.
No file is downloaded; this only updates the counter.
"""
await service.increment_download_count(id)

View File

@ -0,0 +1,75 @@
from typing import List
from fastapi import APIRouter, Body, Depends, status
from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.factory import Factory
from app.core.data_types import UUID7Field
from app.core.params import CommonParams
from app.schemas.base import PaginatedResponse
from app.schemas.news_schema import NewsCreateSchema, NewsSchema, NewsUpdateSchema
from app.services import NewsService
router = APIRouter()
@router.get("/news", response_model=PaginatedResponse[NewsSchema])
async def get_newss(params: CommonParams = Depends(), service: NewsService = Depends(Factory().get_news_service)):
filter = params.filter
sort = params.sort
search = params.search
group_by = params.group_by
limit = params.limit
offset = params.offset
newss, total = await service.find_all(filter, sort, search, group_by, limit, offset)
return PaginatedResponse(
items=[NewsSchema.model_validate(news) for news in newss],
total=total,
limit=limit,
offset=offset,
has_more=total > (offset + limit),
)
@router.get("/news/{id}", response_model=NewsSchema)
async def get_news(id: UUID7Field, service: NewsService = Depends(Factory().get_news_service)):
news = await service.find_by_id(id)
return news
@router.post(
"/news",
response_model=NewsSchema,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_active_user)],
)
async def create_news(data: NewsCreateSchema, service: NewsService = Depends(Factory().get_news_service)):
news = await service.create(data.dict())
return news
@router.patch(
"/news/activation", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_active_user)]
)
async def update_news_activation(
ids: List[UUID7Field] = Body(...),
is_active: bool = Body(...),
service: NewsService = Depends(Factory().get_news_service),
):
await service.bulk_update_activation(ids, is_active)
@router.patch("/news/{id}", response_model=NewsSchema, dependencies=[Depends(get_current_active_user)])
async def update_news(
id: UUID7Field,
data: NewsUpdateSchema,
service: NewsService = Depends(Factory().get_news_service),
):
news = await service.update(id, data.dict(exclude_unset=True))
return news
@router.delete("/news/{id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_active_user)])
async def delete_news(id: UUID7Field, service: NewsService = Depends(Factory().get_news_service)):
await service.delete(id)

View File

@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends, status
from app.api.dependencies.auth import get_current_active_user, get_payload
from app.api.dependencies.factory import Factory
from app.core.data_types import UUID7Field
from app.core.params import CommonParams
from app.schemas.base import PaginatedResponse
from app.schemas.organization_schema import (
OrganizationCreateSchema,
OrganizationSchema,
OrganizationUpdateSchema,
)
from app.schemas.user_schema import UserSchema
from app.services import OrganizationService
router = APIRouter()
@router.get("/organizations", response_model=PaginatedResponse[OrganizationSchema])
async def get_organizations(
params: CommonParams = Depends(),
user: UserSchema = Depends(get_payload),
service: OrganizationService = Depends(Factory().get_organization_service),
landing: bool = False,
):
filter = params.filter
sort = params.sort
search = params.search
group_by = params.group_by
limit = params.limit
offset = params.offset
organizations, total = await service.find_all(user, filter, sort, search, group_by, limit, offset, landing)
return PaginatedResponse(
items=[OrganizationSchema.model_validate(organization) for organization in organizations],
total=total,
limit=limit,
offset=offset,
has_more=total > (offset + limit),
)
@router.get("/organizations/{id}", response_model=OrganizationSchema)
async def get_organization(
id: UUID7Field,
user: UserSchema = Depends(get_payload),
service: OrganizationService = Depends(Factory().get_organization_service),
):
organization = await service.get_organizations_by_id(user, id)
return organization
@router.post(
"/organizations",
response_model=OrganizationSchema,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_active_user)],
)
async def create_organization(
data: OrganizationCreateSchema, service: OrganizationService = Depends(Factory().get_organization_service)
):
organization = await service.create(data.dict())
return organization
@router.patch(
"/organizations/{id}", response_model=OrganizationSchema, dependencies=[Depends(get_current_active_user)]
)
async def update_organization(
id: UUID7Field,
data: OrganizationUpdateSchema,
service: OrganizationService = Depends(Factory().get_organization_service),
):
organization = await service.update(id, data.dict(exclude_unset=True))
return organization
@router.delete(
"/organizations/{id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_active_user)]
)
async def delete_organization(
id: UUID7Field, service: OrganizationService = Depends(Factory().get_organization_service)
):
await service.delete(id)

View File

@ -0,0 +1,68 @@
from fastapi import APIRouter, Depends, status
from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.factory import Factory
from app.core.data_types import UUID7Field
from app.core.params import CommonParams
from app.schemas import RegionalCreateSchema, RegionalSchema, RegionalUpdateSchema
from app.schemas.base import PaginatedResponse
from app.services import RegionalService
router = APIRouter()
@router.get("/regionals", response_model=PaginatedResponse[RegionalSchema])
async def get_regionals(
params: CommonParams = Depends(), service: RegionalService = Depends(Factory().get_regional_service)
):
filter = params.filter
sort = params.sort
search = params.search
group_by = params.group_by
limit = params.limit
offset = params.offset
regionals, total = await service.find_all(filter, sort, search, group_by, limit, offset)
return PaginatedResponse(
items=[RegionalSchema.model_validate(regional) for regional in regionals],
total=total,
limit=limit,
offset=offset,
has_more=total > (offset + limit),
)
@router.get("/regionals/{id}", response_model=RegionalSchema)
async def get_regional(id: UUID7Field, service: RegionalService = Depends(Factory().get_regional_service)):
regional = await service.find_by_id(id)
return regional
@router.post(
"/regionals",
response_model=RegionalSchema,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_active_user)],
)
async def create_regional(
data: RegionalCreateSchema, service: RegionalService = Depends(Factory().get_regional_service)
):
regional = await service.create(data.dict())
return regional
@router.patch("/regionals/{id}", response_model=RegionalSchema, dependencies=[Depends(get_current_active_user)])
async def update_regional(
id: UUID7Field,
data: RegionalUpdateSchema,
service: RegionalService = Depends(Factory().get_regional_service),
):
regional = await service.update(id, data.dict(exclude_unset=True))
return regional
@router.delete(
"/regionals/{id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_active_user)]
)
async def delete_regional(id: UUID7Field, service: RegionalService = Depends(Factory().get_regional_service)):
await service.delete(id)

View File

@ -0,0 +1,62 @@
from fastapi import APIRouter, Depends, status
from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.factory import Factory
from app.core.data_types import UUID7Field
from app.core.params import CommonParams
from app.schemas.base import PaginatedResponse
from app.schemas.role_schema import RoleCreateSchema, RoleSchema, RoleUpdateSchema
from app.services import RoleService
router = APIRouter()
@router.get("/roles", response_model=PaginatedResponse[RoleSchema])
async def get_roles(params: CommonParams = Depends(), service: RoleService = Depends(Factory().get_role_service)):
filter = params.filter
sort = params.sort
search = params.search
group_by = params.group_by
limit = params.limit
offset = params.offset
roles, total = await service.find_all(filter, sort, search, group_by, limit, offset)
return PaginatedResponse(
items=[RoleSchema.model_validate(role) for role in roles],
total=total,
limit=limit,
offset=offset,
has_more=total > (offset + limit),
)
@router.get("/roles/{id}", response_model=RoleSchema)
async def get_role(id: UUID7Field, service: RoleService = Depends(Factory().get_role_service)):
role = await service.find_by_id(id)
return role
@router.post(
"/roles",
response_model=RoleSchema,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_active_user)],
)
async def create_role(data: RoleCreateSchema, service: RoleService = Depends(Factory().get_role_service)):
role = await service.create(data.dict())
return role
@router.patch("/roles/{id}", response_model=RoleSchema, dependencies=[Depends(get_current_active_user)])
async def update_role(
id: UUID7Field,
data: RoleUpdateSchema,
service: RoleService = Depends(Factory().get_role_service),
):
role = await service.update(id, data.dict(exclude_unset=True))
return role
@router.delete("/roles/{id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_active_user)])
async def delete_role(id: UUID7Field, service: RoleService = Depends(Factory().get_role_service)):
await service.delete(id)

View File

@ -0,0 +1,94 @@
from typing import List
from fastapi import APIRouter, Body, Depends, status
from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.factory import Factory
from app.core.data_types import UUID7Field
from app.core.params import CommonParams
from app.schemas.base import PaginatedResponse
from app.schemas.user_schema import UserCreateSchema, UserSchema, UserUpdateSchema
from app.services import UserService
router = APIRouter()
@router.get("/users", response_model=PaginatedResponse[UserSchema])
async def get_users(
params: CommonParams = Depends(),
user_active: UserSchema = Depends(get_current_active_user),
service: UserService = Depends(Factory().get_user_service),
):
filter = params.filter
sort = params.sort
search = params.search
group_by = params.group_by
limit = params.limit
offset = params.offset
users, total = await service.find_all(
filters=filter,
sort=sort,
search=search,
group_by=group_by,
limit=limit,
offset=offset,
user=user_active,
)
return PaginatedResponse(
items=[UserSchema.model_validate(user) for user in users],
total=total,
limit=limit,
offset=offset,
has_more=total > (offset + limit),
)
@router.get("/users/{id}", response_model=UserSchema)
async def get_user(
id: UUID7Field,
user: UserSchema = Depends(get_current_active_user),
service: UserService = Depends(Factory().get_user_service),
):
user = await service.find_by_id(id, user)
return user
@router.post("/users", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
async def create_user(
data: UserCreateSchema,
user: UserSchema = Depends(get_current_active_user),
service: UserService = Depends(Factory().get_user_service),
):
user = await service.create(data.dict(), user)
return user
@router.patch("/users/activation", status_code=status.HTTP_204_NO_CONTENT)
async def update_user_activation(
ids: List[UUID7Field] = Body(...),
is_active: bool = Body(...),
user: UserSchema = Depends(get_current_active_user),
service: UserService = Depends(Factory().get_user_service),
):
await service.bulk_update_activation(ids, is_active, user)
@router.patch("/users/{id}", response_model=UserSchema)
async def update_user(
id: UUID7Field,
data: UserUpdateSchema,
user: UserSchema = Depends(get_current_active_user),
service: UserService = Depends(Factory().get_user_service),
):
user = await service.update(id, data.dict(exclude_unset=True), user)
return user
@router.delete(
"/users/{id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_active_user)],
)
async def delete_user(id: UUID7Field, service: UserService = Depends(Factory().get_user_service)):
await service.delete(id)

0
app/core/__init__.py Normal file
View File

88
app/core/config.py Normal file
View File

@ -0,0 +1,88 @@
from functools import lru_cache
from typing import List, Optional
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.utils.system import get_optimal_workers
class Settings(BaseSettings):
# Application settings
PROJECT_NAME: str = Field(default="Satu Peta")
VERSION: str = Field(default="0.1.0")
DESCRIPTION: str = Field(default="Satu Peta API")
# Server settings
DEBUG: bool = Field(default=False)
HOST: str = Field(default="127.0.0.1")
PORT: int = Field(default=8001)
WORKERS: int = Field(default=get_optimal_workers())
LOG_LEVEL: str = Field(default="info")
LOOP: str = Field(default="uvloop")
HTTP: str = Field(default="httptools")
LIMIT_CONCURRENCY: int = Field(default=100)
BACKLOG: int = Field(default=2048)
LIMIT_MAX_REQUESTS: int | None = Field(default=None)
TIMEOUT_KEEP_ALIVE: int = Field(default=5)
H11_MAX_INCOMPLETE_EVENT_SIZE: int = Field(default=16 * 1024)
SERVER_HEADER: str = Field(default=f"{PROJECT_NAME}/{VERSION}")
FORWARDED_ALLOW_IPS: str = Field(default="*")
DATE_HEADER: bool = Field(default=True)
@property
def ACCESS_LOG(self) -> bool:
return self.DEBUG
# Database settings
DATABASE_URL: str
# Security settings
SECRET_KEY: str
ALGORITHM: str = Field(default="HS256")
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30)
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7)
# Cors settings
ALLOWED_ORIGINS: List[str] = Field(default=["*"])
# S3/MinIO settings
MINIO_ENDPOINT_URL: str = Field(default="http://localhost:9000")
MINIO_ROOT_USER: str
MINIO_ROOT_PASSWORD: str
MINIO_SECURE: Optional[bool] = False
MINIO_BUCKET_NAME: Optional[str] = Field(default="satu-peta")
MINIO_REGION: Optional[str] = Field(default=None)
MAX_UPLOAD_SIZE: int = 100 * 1024 * 1024 # 100MB default limit
ALLOWED_EXTENSIONS: List[str] = [
"jpg",
"jpeg",
"png",
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"txt",
"csv",
"zip",
"rar",
"json",
]
TIMEZONE: str = Field(default="Asia/Jakarta")
# GeoNetwork settings
GEONETWORK_API_URL: str = Field(default="https://geonetwork.jatimprov.go.id/geonetwork/srv/api/search/records/_search")
# Settings config
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=True, extra="allow")
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

39
app/core/data_types.py Normal file
View File

@ -0,0 +1,39 @@
from typing import Any
from pydantic import GetCoreSchemaHandler
from pydantic_core import CoreSchema, PydanticCustomError
from pydantic_core.core_schema import (
is_instance_schema,
json_or_python_schema,
no_info_plain_validator_function,
plain_serializer_function_ser_schema,
str_schema,
union_schema,
)
from uuid6 import UUID
class UUID7Field(UUID):
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: Any,
handler: GetCoreSchemaHandler,
) -> CoreSchema:
return json_or_python_schema(
json_schema=str_schema(),
python_schema=union_schema([is_instance_schema(cls), no_info_plain_validator_function(cls.validate)]),
serialization=plain_serializer_function_ser_schema(
lambda x: str(x),
return_schema=str_schema(),
),
)
@classmethod
def validate(cls, v):
if isinstance(v, UUID):
return v
try:
return UUID(str(v))
except ValueError as e:
raise PydanticCustomError("uuid_parsing", "Invalid UUID format") from e

24
app/core/database.py Normal file
View File

@ -0,0 +1,24 @@
import asyncio
import os
import sys
from sqlalchemy.ext.asyncio import create_async_engine
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
import os
import sys
from app.core.config import settings
from app.models import Base
engine = create_async_engine(settings.DATABASE_URL, echo=True)
async def create_tables():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
if __name__ == "__main__":
asyncio.run(create_tables())

63
app/core/exceptions.py Normal file
View File

@ -0,0 +1,63 @@
from http import HTTPStatus
from typing import Any, Dict, Optional, Type
from fastapi import status
class APIException(Exception):
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR
default_message: str = "Internal server error"
def __init__(
self, message: Optional[str] = None, detail: Optional[Any] = None, headers: Optional[Dict[str, str]] = None
):
self.message = message or self.default_message
self.detail = detail
self.headers = headers or {}
super().__init__(self.message)
def create_exception(name: str, status_code: int, default_message: str) -> Type[APIException]:
return type(name, (APIException,), {"status_code": status_code, "default_message": default_message})
def prepare_error_response(message: str, detail: Any = None, error_type: Optional[str] = None) -> Dict[str, Any]:
response = {"detail": message}
if detail is not None:
if isinstance(detail, dict):
response.update(detail)
else:
response["additional_info"] = detail
if error_type:
response["error_type"] = error_type
return response
BadRequestException = create_exception(
"BadRequestException", status.HTTP_400_BAD_REQUEST, HTTPStatus.BAD_REQUEST.description
)
NotFoundException = create_exception("NotFoundException", status.HTTP_404_NOT_FOUND, HTTPStatus.NOT_FOUND.description)
ForbiddenException = create_exception(
"ForbiddenException", status.HTTP_403_FORBIDDEN, HTTPStatus.FORBIDDEN.description
)
UnauthorizedException = create_exception(
"UnauthorizedException", status.HTTP_401_UNAUTHORIZED, HTTPStatus.UNAUTHORIZED.description
)
UnprocessableEntity = create_exception(
"UnprocessableEntity", status.HTTP_422_UNPROCESSABLE_ENTITY, HTTPStatus.UNPROCESSABLE_ENTITY.description
)
DuplicateValueException = create_exception(
"DuplicateValueException", status.HTTP_422_UNPROCESSABLE_ENTITY, HTTPStatus.UNPROCESSABLE_ENTITY.description
)
InvalidInputException = create_exception(
"InvalidInputException", status.HTTP_422_UNPROCESSABLE_ENTITY, HTTPStatus.UNPROCESSABLE_ENTITY.description
)

193
app/core/minio_client.py Normal file
View File

@ -0,0 +1,193 @@
from typing import Any, BinaryIO, Dict, List, Optional, Tuple
from urllib.parse import urlparse
import aiohttp
from fastapi import HTTPException, status
from miniopy_async import Minio
from miniopy_async.error import S3Error
from app.core.config import settings
class MinioClient:
"""
Client class untuk interaksi dengan MinIO Object Storage secara asinkron.
"""
def __init__(self):
self.client = Minio(
endpoint=settings.MINIO_ENDPOINT_URL,
access_key=settings.MINIO_ROOT_USER,
secret_key=settings.MINIO_ROOT_PASSWORD,
secure=settings.MINIO_SECURE,
region=settings.MINIO_REGION,
)
self.bucket_name = settings.MINIO_BUCKET_NAME
async def init_bucket(self) -> None:
"""
Inisialisasi bucket jika belum ada.
"""
try:
if not await self.client.bucket_exists(self.bucket_name):
await self.client.make_bucket(self.bucket_name)
# Set bucket policy agar dapat diakses publik jika diperlukan
# policy = {...} # Define your policy if needed
# await self.client.set_bucket_policy(self.bucket_name, json.dumps(policy))
except S3Error as err:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error initializing MinIO bucket: {str(err)}",
)
async def upload_file(
self,
file_data: BinaryIO,
object_name: str,
content_type: str,
content_length: int,
metadata: Optional[Dict[str, str]] = None,
) -> str:
"""
Upload file ke MinIO.
Args:
file_data: File-like object untuk diupload
object_name: Nama objek di MinIO
content_type: Tipe konten file
metadata: Metadata tambahan untuk objek
Returns:
URL objek yang telah diupload
"""
try:
await self.init_bucket()
# Upload file
await self.client.put_object(
bucket_name=self.bucket_name,
object_name=object_name,
data=file_data,
length=content_length,
content_type=content_type,
metadata=metadata,
)
# Generate URL
url = await self.get_file_url(object_name)
return url
except S3Error as err:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error uploading file to MinIO: {str(err)}"
)
async def get_file(self, object_name: str) -> Tuple[BinaryIO, Dict[str, Any]]:
"""
Ambil file dari MinIO.
Args:
object_name: Nama objek di MinIO
Returns:
Tuple dari (file data, object info)
"""
try:
stat = await self.client.stat_object(bucket_name=self.bucket_name, object_name=object_name)
session = aiohttp.ClientSession()
response = await self.client.get_object(
bucket_name=self.bucket_name, object_name=object_name, session=session
)
return response, stat.__dict__
except S3Error as err:
if err.code == "NoSuchKey":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving file from MinIO: {str(err)}",
)
async def delete_file(self, object_name: str) -> bool:
"""
Hapus file dari MinIO.
Args:
object_name: Nama objek di MinIO
Returns:
Boolean yang menunjukkan keberhasilan operasi
"""
try:
await self.client.remove_object(self.bucket_name, object_name)
return True
except S3Error as err:
if err.code == "NoSuchKey":
return False
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error deleting file from MinIO: {str(err)}"
)
async def get_file_url(self, object_name: str) -> str:
"""
Dapatkan URL untuk mengakses file.
Args:
object_name: Nama objek di MinIO
Returns:
URL untuk mengakses file
"""
try:
# For public access
if settings.MINIO_SECURE:
protocol = "https"
else:
protocol = "http"
parsed_endpoint = urlparse(settings.MINIO_ENDPOINT_URL)
host = parsed_endpoint.netloc or settings.MINIO_ENDPOINT_URL
return f"{protocol}://{host}/{self.bucket_name}/{object_name}"
# For presigned URL (time-limited access):
# return await self.client.presigned_get_object(
# bucket_name=self.bucket_name,
# object_name=object_name,
# expires=timedelta(hours=1)
# )
except S3Error as err:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error generating URL: {str(err)}"
)
async def list_files(self, prefix: str = "", recursive: bool = True) -> List[Dict[str, Any]]:
"""
Daftar semua file di dalam bucket dengan prefix tertentu.
Args:
prefix: Awalan objek yang dicari
recursive: Jika True, juga mencari di subdirektori
Returns:
List dari item objek
"""
try:
objects = []
async for obj in self.client.list_objects(self.bucket_name, prefix=prefix, recursive=recursive):
objects.append(
{
"name": obj.object_name,
"size": obj.size,
"last_modified": obj.last_modified,
"etag": obj.etag,
}
)
return objects
except S3Error as err:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error listing files: {str(err)}"
)

36
app/core/params.py Normal file
View File

@ -0,0 +1,36 @@
import json
from typing import Optional
from fastapi import Query
class CommonParams:
def __init__(
self,
filter: Optional[str] = Query(default=None),
sort: Optional[str] = Query(default=None),
search: str = Query(default=""),
group_by: Optional[str] = Query(default=None),
limit: int = Query(default=100, ge=1),
offset: int = Query(default=0, ge=0),
):
if filter:
try:
self.filter = json.loads(filter)
except Exception:
self.filter = filter
else:
self.filter = []
if sort:
try:
self.sort = json.loads(sort)
except Exception:
self.sort = sort
else:
self.sort = []
self.search = search
self.group_by = group_by
self.limit = limit
self.offset = offset

16
app/core/responses.py Normal file
View File

@ -0,0 +1,16 @@
from typing import Any, override
from fastapi.responses import JSONResponse
from app.utils.helpers import orjson_dumps
class ORJSONResponse(JSONResponse):
"""Custom JSONResponse menggunakan orjson."""
media_type = "application/json"
@override
def render(self, content: Any) -> bytes:
"""Render content menggunakan orjson."""
return orjson_dumps(content)

80
app/core/security.py Normal file
View File

@ -0,0 +1,80 @@
# app/core/security.py
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, Union
from fastapi import HTTPException
from jose import ExpiredSignatureError, JWTError, jwt
from passlib.context import CryptContext
from pytz import timezone
from app.core.config import settings
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verifikasi password."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash password."""
return pwd_context.hash(password)
def create_token(
subject: Union[str, Any], expires_delta: Optional[timedelta] = None, token_type: str = "access"
) -> str:
"""Buat JWT token."""
if expires_delta:
expire = datetime.now(timezone(settings.TIMEZONE)) + expires_delta
else:
if token_type == "access":
expire = datetime.now(timezone(settings.TIMEZONE)) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
elif token_type == "refresh":
expire = datetime.now(timezone(settings.TIMEZONE)) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
else:
expire = datetime.now(timezone(settings.TIMEZONE)) + timedelta(minutes=15)
to_encode = {
"exp": expire,
"iat": datetime.now(timezone(settings.TIMEZONE)),
"sub": str(subject),
"type": token_type,
}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_access_token(subject: Union[str, Any]) -> str:
"""Buat access token."""
return create_token(subject, token_type="access")
def create_refresh_token(subject: Union[str, Any]) -> str:
"""Buat refresh token."""
return create_token(subject, token_type="refresh")
def decode_token(token: str) -> Dict[str, Any]:
try:
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
options={
"verify_signature": True,
"verify_exp": True,
"verify_iat": True,
},
)
return payload
except ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")

117
app/main.py Normal file
View File

@ -0,0 +1,117 @@
# local run
# pyenv local 3.13.0
# poetry env use python
# poetry install
# poetry install --no-root
# poetry run uvicorn app.main:app --reload
from contextlib import asynccontextmanager
from asyncpg.exceptions import ForeignKeyViolationError
from brotli_asgi import BrotliMiddleware
from fastapi import FastAPI, Request, status
from fastapi.exceptions import HTTPException, RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, ORJSONResponse
from fastapi_async_sqlalchemy import SQLAlchemyMiddleware
from sqlalchemy.exc import IntegrityError
from app.api.v1 import router as api_router
from app.core.config import settings
from app.core.exceptions import APIException, prepare_error_response
from app.utils.system import optimize_system
@asynccontextmanager
async def lifespan(app: FastAPI):
await optimize_system()
yield
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
description=settings.DESCRIPTION,
default_response_class=ORJSONResponse,
lifespan=lifespan,
root_path="/api",
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
)
app.add_middleware(
BrotliMiddleware,
minimum_size=1000,
)
app.add_middleware(
SQLAlchemyMiddleware,
db_url=settings.DATABASE_URL,
engine_args={"echo": settings.DEBUG},
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
@app.exception_handler(APIException)
async def api_exception_handler(request: Request, exc: APIException):
response_content = prepare_error_response(message=exc.message)
return JSONResponse(status_code=exc.status_code, content=response_content, headers=exc.headers)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
response_content = prepare_error_response(message=str(exc.detail))
return JSONResponse(status_code=exc.status_code, content=response_content, headers=exc.headers)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
response_content = prepare_error_response(message=exc.errors())
return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content=response_content)
@app.exception_handler(LookupError)
async def enum_exception_handler(request: Request, exc: LookupError):
error_message = str(exc)
if "is not among the defined enum values" in error_message:
response_content = prepare_error_response(
message="Invalid enum value",
detail=error_message,
)
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=response_content)
raise exc
@app.exception_handler(ForeignKeyViolationError)
@app.exception_handler(IntegrityError)
async def global_exception_handler(request: Request, exc: Exception):
response_content = prepare_error_response(
message="Foreign key violation",
detail=str(exc) if settings.DEBUG else None,
)
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=response_content)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
response_content = prepare_error_response(
message="Internal server error", detail=str(exc) if settings.DEBUG else None
)
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content=response_content)

38
app/models/__init__.py Normal file
View File

@ -0,0 +1,38 @@
from .base import Base
from .category_model import CategoryModel
from .classification_model import ClassificationModel
from .credential_model import CredentialModel
from .file_model import FileModel
from .map_access_model import MapAccessModel
from .map_projection_system_model import MapProjectionSystemModel
from .map_source_model import MapSourceModel, SourceUsageModel
from .mapset_history_model import MapsetHistoryModel
from .mapset_model import MapsetModel
from .news_model import NewsModel
from .organization_model import OrganizationModel
from .refresh_token_model import RefreshTokenModel
from .regional_model import RegionalModel
from .role_model import RoleModel
from .user_model import UserModel
from .feedback_model import FeedbackModel
__all__ = [
"Base",
"OrganizationModel",
"RoleModel",
"UserModel",
"RefreshTokenModel",
"NewsModel",
"FileModel",
"CredentialModel",
"MapsetModel",
"MapSourceModel",
"MapProjectionSystemModel",
"MapAccessModel",
"MapsetHistoryModel",
"CategoryModel",
"ClassificationModel",
"RegionalModel",
"SourceUsageModel",
"FeedbackModel",
]

12
app/models/base.py Normal file
View File

@ -0,0 +1,12 @@
from typing import Any, Dict
from sqlalchemy.orm import declarative_base
from app.utils.helpers import orm_to_dict
class Base(declarative_base()):
__abstract__ = True
def to_dict(self) -> Dict[str, Any]:
return orm_to_dict(self)

View File

@ -0,0 +1,16 @@
import uuid6
from sqlalchemy import UUID, Boolean, Column, Integer, String, Text, text
from . import Base
class CategoryModel(Base):
__tablename__ = "categories"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
name = Column(String)
description = Column(Text)
thumbnail = Column(String)
count_mapset = Column(Integer, default=0, server_default=text("0"))
is_active = Column(Boolean, default=True)
order = Column(Integer, default=0)

View File

@ -0,0 +1,14 @@
import uuid6
from sqlalchemy import UUID, Boolean, Column, String
from . import Base
class ClassificationModel(Base):
__tablename__ = "classifications"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
name = Column(String(20), nullable=False)
is_open = Column(Boolean, default=True)
is_limited = Column(Boolean, default=False)
is_secret = Column(Boolean, default=False)

View File

@ -0,0 +1,28 @@
from datetime import datetime
import uuid6
from sqlalchemy import JSON, UUID, Boolean, Column, DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped
from . import Base
class CredentialModel(Base):
__tablename__ = "credentials"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
name = Column(String)
description = Column(Text)
encrypted_data = Column(Text, nullable=False)
encryption_iv = Column(String(255), nullable=False)
credential_type = Column(String(50), nullable=False)
credential_metadata: Mapped[dict] = Column(JSON, nullable=True)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
updated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), default=datetime.now)
updated_at = Column(DateTime(timezone=True), onupdate=datetime.now)
last_used_at = Column(DateTime(timezone=True), nullable=True)
last_used_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
is_default = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
is_deleted = Column(Boolean, default=False)

View File

@ -0,0 +1,23 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text, func
from . import Base
class FeedbackModel(Base):
__tablename__ = "feedback"
id = Column(Integer, primary_key=True, autoincrement=True)
datetime = Column(DateTime, default=datetime.now)
score = Column(Integer)
tujuan_tercapai = Column(Boolean, default=True)
tujuan_ditemukan = Column(Boolean, default=True)
tujuan = Column(String)
sektor = Column(String)
email = Column(String)
saran = Column(Text)
source_url = Column(String)
source_access = Column(String)
notes = Column(Text)
gender = Column(Integer)

31
app/models/file_model.py Normal file
View File

@ -0,0 +1,31 @@
from datetime import datetime
import uuid6
from pytz import timezone
from sqlalchemy import UUID, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.config import settings
from . import Base
class FileModel(Base):
__tablename__ = "files"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid6.uuid7, index=True)
filename = Column(String(255), nullable=False, index=True)
object_name = Column(String(512), nullable=False, unique=True)
content_type = Column(String(100), nullable=False)
size = Column(Integer, nullable=False)
description = Column(Text, nullable=True)
url = Column(String(1024), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.now(timezone(settings.TIMEZONE)))
modified_at = Column(
DateTime(timezone=True),
default=datetime.now(timezone(settings.TIMEZONE)),
onupdate=datetime.now(timezone(settings.TIMEZONE)),
)
uploaded_by = relationship("UserModel", lazy="selectin", uselist=False)

View File

@ -0,0 +1,45 @@
from datetime import datetime
from typing import Optional
from pytz import timezone
from sqlalchemy import UUID as SQLUUID
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped
from uuid6 import UUID, uuid7
from app.core.config import settings
from . import Base
class MapAccessModel(Base):
__tablename__ = "mapset_access"
id: Mapped[UUID] = Column(String, primary_key=True, default=uuid7)
mapset_id: Mapped[UUID] = Column(
SQLUUID(as_uuid=True), ForeignKey("mapsets.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[Optional[UUID]] = Column(
SQLUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
organization_id: Mapped[Optional[UUID]] = Column(
SQLUUID(as_uuid=True), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=True
)
granted_by: Mapped[UUID] = Column(SQLUUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
can_read: Mapped[bool] = Column(Boolean, default=True)
can_write: Mapped[bool] = Column(Boolean, default=False)
can_delete: Mapped[bool] = Column(Boolean, default=False)
created_at: Mapped[datetime] = Column(DateTime(timezone=True), default=datetime.now(timezone(settings.TIMEZONE)))
updated_at: Mapped[datetime] = Column(
DateTime(timezone=True),
default=datetime.now(timezone(settings.TIMEZONE)),
onupdate=datetime.now(timezone(settings.TIMEZONE)),
)
# expires_at: Mapped[Optional[datetime]] = Column(DateTime(timezone=True), nullable=True) # Optional expiry
# Relationships
# mapset = relationship("MapsetModel", back_populates="access_grants")
# user = relationship("UserModel", foreign_keys=[user_id], back_populates="mapset_access")
# organization = relationship("OrganizationModel", back_populates="mapset_access")
# grantor = relationship("UserModel", foreign_keys=[granted_by])

View File

@ -0,0 +1,11 @@
import uuid6
from sqlalchemy import UUID, Column, String
from . import Base
class MapProjectionSystemModel(Base):
__tablename__ = "map_projection_systems"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
name = Column(String(50), nullable=False)

View File

@ -0,0 +1,56 @@
from datetime import datetime
import uuid6
from pytz import timezone
from sqlalchemy import UUID, Boolean, Column, DateTime, ForeignKey, String, Text
from sqlalchemy.orm import relationship
from app.core.config import settings
from . import Base
class MapSourceModel(Base):
__tablename__ = "map_sources"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
name = Column(String(50), nullable=False)
description = Column(Text)
url = Column(Text, nullable=True)
credential_id = Column(UUID(as_uuid=True), ForeignKey("credentials.id"))
is_active = Column(Boolean, default=True)
is_deleted = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), default=datetime.now(timezone(settings.TIMEZONE)))
updated_at = Column(
DateTime(timezone=True),
default=datetime.now(timezone(settings.TIMEZONE)),
onupdate=datetime.now(timezone(settings.TIMEZONE)),
)
usages = relationship("SourceUsageModel", back_populates="source", lazy="selectin")
mapsets = relationship(
"MapsetModel",
secondary="source_usages",
primaryjoin="MapSourceModel.id == SourceUsageModel.source_id",
secondaryjoin="SourceUsageModel.mapset_id == MapsetModel.id",
lazy="selectin",
viewonly=True,
)
credential = relationship("CredentialModel", lazy="selectin", uselist=False)
class SourceUsageModel(Base):
__tablename__ = "source_usages"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
source_id = Column(UUID(as_uuid=True), ForeignKey("map_sources.id"), nullable=False)
mapset_id = Column(UUID(as_uuid=True), ForeignKey("mapsets.id"), nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.now(timezone(settings.TIMEZONE)))
updated_at = Column(
DateTime(timezone=True),
default=datetime.now(timezone(settings.TIMEZONE)),
onupdate=datetime.now(timezone(settings.TIMEZONE)),
)
mapset = relationship("MapsetModel", back_populates="source_usages")
source = relationship("MapSourceModel", back_populates="usages")

View File

@ -0,0 +1,27 @@
from datetime import datetime
import uuid6
from pytz import timezone
from sqlalchemy import UUID, Column, DateTime, ForeignKey, String, Text
from sqlalchemy.orm import relationship
from app.core.config import settings
from . import Base
class MapsetHistoryModel(Base):
"""Model untuk melacak riwayat perubahan pada mapset."""
__tablename__ = "mapset_histories"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
mapset_id = Column(UUID(as_uuid=True), ForeignKey("mapsets.id"), index=True, comment="ID mapset yang dilacak")
validation_type = Column(String(50), nullable=False, comment="Jenis perubahan pada mapset")
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), comment="ID pengguna yang melakukan perubahan")
notes = Column(Text, nullable=True, comment="Catatan detail perubahan yang dilakukan")
timestamp = Column(
DateTime(timezone=True), default=datetime.now(timezone(settings.TIMEZONE)), comment="Waktu perubahan tercatat"
)
user = relationship("UserModel", uselist=False, lazy="selectin")

View File

@ -0,0 +1,69 @@
from datetime import datetime
from enum import Enum
import uuid6
from pytz import timezone
from sqlalchemy import UUID, Boolean, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.config import settings
from . import Base
class MapsetStatus(str, Enum):
approved = "approved"
rejected = "rejected"
on_verification = "on_verification"
class MapsetModel(Base):
__tablename__ = "mapsets"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
scale = Column(String(29), nullable=False)
layer_url = Column(Text)
layer_type = Column(String(20), nullable=True)
metadata_url = Column(Text)
category_id = Column(UUID(as_uuid=True), ForeignKey("categories.id"))
classification_id = Column(UUID(as_uuid=True), ForeignKey("classifications.id"))
regional_id = Column(UUID(as_uuid=True), ForeignKey("regionals.id"), nullable=True)
projection_system_id = Column(UUID(as_uuid=True), ForeignKey("map_projection_systems.id"))
producer_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"))
status_validation = Column(String(20), nullable=True)
data_status = Column(String(20), nullable=False)
data_update_period = Column(String(20), nullable=False)
data_version = Column(String(20), nullable=False)
coverage_level = Column(String(20), nullable=True)
coverage_area = Column(String(20), nullable=True)
view_count = Column(Integer, default=0)
download_count = Column(Integer, default=0)
order = Column(Integer, default=0)
is_popular = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
is_deleted = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), default=datetime.now(timezone(settings.TIMEZONE)))
updated_at = Column(
DateTime(timezone=True),
default=datetime.now(timezone(settings.TIMEZONE)),
onupdate=datetime.now(timezone(settings.TIMEZONE)),
)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"))
updated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"))
projection_system = relationship("MapProjectionSystemModel", uselist=False, lazy="selectin")
classification = relationship("ClassificationModel", uselist=False, lazy="selectin")
category = relationship("CategoryModel", uselist=False, lazy="selectin")
regional = relationship("RegionalModel", uselist=False, lazy="selectin")
source_usages = relationship("SourceUsageModel", back_populates="mapset", lazy="selectin")
sources = relationship(
"MapSourceModel",
secondary="source_usages",
primaryjoin="MapsetModel.id == SourceUsageModel.mapset_id",
secondaryjoin="SourceUsageModel.source_id == MapSourceModel.id",
lazy="selectin",
viewonly=True,
)
producer = relationship("OrganizationModel", back_populates="mapsets", uselist=False, lazy="joined")

29
app/models/news_model.py Normal file
View File

@ -0,0 +1,29 @@
from datetime import datetime
import uuid6
from pytz import timezone
from sqlalchemy import UUID, Boolean, Column, DateTime, String, Text, func
from app.core.config import settings
from . import Base
class NewsModel(Base):
__tablename__ = "news"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
name = Column(String)
description = Column(Text)
thumbnail = Column(String)
created_at = Column(
DateTime(timezone=True), server_default=func.now(), default=datetime.now(timezone(settings.TIMEZONE))
)
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
default=datetime.now(timezone(settings.TIMEZONE)),
)
is_active = Column(Boolean, default=True)
is_deleted = Column(Boolean, default=False)

View File

@ -0,0 +1,42 @@
from datetime import datetime
import uuid6
from pytz import timezone
from sqlalchemy import UUID, Boolean, Column, DateTime, String, Text
from sqlalchemy.orm import relationship
from app.core.config import settings
from . import Base
class OrganizationModel(Base):
__tablename__ = "organizations"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
thumbnail = Column(String(255), nullable=True)
address = Column(String(255), nullable=True)
phone_number = Column(String(15), nullable=True)
email = Column(String(100), nullable=True)
website = Column(String(255), nullable=True)
is_active = Column(Boolean, default=True, server_default="true")
is_deleted = Column(Boolean, default=False, server_default="false")
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.now(timezone(settings.TIMEZONE)))
modified_at = Column(
DateTime(timezone=True),
nullable=True,
default=datetime.now(timezone(settings.TIMEZONE)),
onupdate=datetime.now(timezone(settings.TIMEZONE)),
)
users = relationship("UserModel", lazy="selectin")
mapsets = relationship("MapsetModel", lazy="selectin")
# @property
# def count_mapset(self):
# if self.mapsets is None:
# return 0
# else:
# return len(self.mapsets)

View File

@ -0,0 +1,23 @@
from datetime import datetime
import uuid6
from pytz import timezone
from sqlalchemy import UUID, Boolean, Column, DateTime, ForeignKey, String
from sqlalchemy.orm import relationship
from app.core.config import settings
from . import Base
class RefreshTokenModel(Base):
__tablename__ = "refresh_tokens"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
token = Column(String(255), nullable=False, index=True)
expires_at = Column(DateTime(timezone=True), nullable=False)
revoked = Column(Boolean, default=False, server_default="false")
created_at = Column(DateTime(timezone=True), default=datetime.now(timezone(settings.TIMEZONE)))
user = relationship("UserModel", lazy="selectin", uselist=False)

View File

@ -0,0 +1,26 @@
from datetime import datetime
import uuid6
from pytz import timezone
from sqlalchemy import UUID, Boolean, Column, DateTime, String, Text
from app.core.config import settings
from . import Base
class RegionalModel(Base):
__tablename__ = "regionals"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
code = Column(String(10), nullable=False)
name = Column(String(50), nullable=False)
description = Column(Text, nullable=True)
thumbnail = Column(String(255), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.now(timezone(settings.TIMEZONE)))
updated_at = Column(
DateTime(timezone=True),
default=datetime.now(timezone(settings.TIMEZONE)),
onupdate=datetime.now(timezone(settings.TIMEZONE)),
)

32
app/models/role_model.py Normal file
View File

@ -0,0 +1,32 @@
from datetime import datetime
import uuid6
from pytz import timezone
from sqlalchemy import UUID, Boolean, Column, DateTime, String, Text
from sqlalchemy.orm import relationship
from app.core.config import settings
from . import Base
class RoleModel(Base):
__tablename__ = "roles"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
name = Column(String(20), nullable=False)
description = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.now(timezone(settings.TIMEZONE)))
updated_at = Column(
DateTime(timezone=True),
default=datetime.now(timezone(settings.TIMEZONE)),
onupdate=datetime.now(timezone(settings.TIMEZONE)),
)
users = relationship("UserModel", lazy="selectin")
# Relationships
# organization = relationship("OrganizationModel", back_populates="members")
# produced_mapsets = relationship("MapsetModel", back_populates="producer")
# mapset_access = relationship("MapsetAccessModel", foreign_keys=[MapsetAccessModel.user_id], back_populates="user")

36
app/models/user_model.py Normal file
View File

@ -0,0 +1,36 @@
from datetime import datetime
import uuid6
from pytz import timezone
from sqlalchemy import UUID, Boolean, Column, DateTime, ForeignKey, String
from sqlalchemy.orm import relationship
from app.core.config import settings
from . import Base
class UserModel(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid6.uuid7)
name = Column(String, nullable=False)
email = Column(String, unique=True, nullable=False)
profile_picture = Column(String, nullable=True)
username = Column(String, unique=True, nullable=False)
password = Column(String, nullable=False)
position = Column(String, nullable=True)
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=False)
employee_id = Column(String, nullable=True)
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False)
is_active = Column(Boolean, default=True)
is_deleted = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), default=datetime.now(timezone(settings.TIMEZONE)))
modified_at = Column(
DateTime(timezone=True),
default=datetime.now(timezone(settings.TIMEZONE)),
onupdate=datetime.now(timezone(settings.TIMEZONE)),
)
organization = relationship("OrganizationModel", back_populates="users", lazy="selectin", uselist=False)
role = relationship("RoleModel", back_populates="users", lazy="selectin", uselist=False)

View File

@ -0,0 +1,39 @@
from .base import BaseRepository
from .category_repository import CategoryRepository
from .classification_repository import ClassificationRepository
from .credential_repository import CredentialRepository
from .file_repository import FileRepository
from .map_access_repository import MapAccessRepository
from .map_projection_system_repository import MapProjectionSystemRepository
from .map_source_repository import MapSourceRepository
from .map_source_usage_repository import SourceUsageRepository
from .mapset_history_repository import MapsetHistoryRepository
from .mapset_repository import MapsetRepository
from .news_repository import NewsRepository
from .organization_repository import OrganizationRepository
from .regional_repository import RegionalRepository
from .role_repository import RoleRepository
from .token_repository import TokenRepository
from .user_repository import UserRepository
from .feedback_repository import FeedbackRepository
__all__ = [
"BaseRepository",
"OrganizationRepository",
"RoleRepository",
"UserRepository",
"TokenRepository",
"NewsRepository",
"FileRepository",
"CredentialRepository",
"MapSourceRepository",
"MapProjectionSystemRepository",
"MapAccessRepository",
"CategoryRepository",
"ClassificationRepository",
"FeedbackRepository",
"RegionalRepository",
"MapsetRepository",
"MapsetHistoryRepository",
"SourceUsageRepository",
]

178
app/repositories/base.py Normal file
View File

@ -0,0 +1,178 @@
from typing import Any, Dict, Generic, List, Optional, Tuple, Type, TypeVar
from fastapi_async_sqlalchemy import db
from sqlalchemy import String, cast
from sqlalchemy import delete as sqlalchemy_delete
from sqlalchemy import func, or_, select
from sqlalchemy import update as sqlalchemy_update
from sqlalchemy.orm import joinedload, selectinload
from uuid6 import UUID
from app.core.database import Base
ModelType = TypeVar("ModelType", bound=Base)
class BaseRepository(Generic[ModelType]):
"""Optimized base repository with fastapi-async-sqlalchemy."""
def __init__(self, model: Type[ModelType]):
self.model: Type[ModelType] = model
def build_base_query(self, include_deleted: bool = False):
"""Build base query dengan soft delete handling."""
query = select(self.model)
if hasattr(self.model, "is_deleted") and not include_deleted:
query = query.where(self.model.is_deleted.is_(False))
return query
async def find_by_id(self, id: UUID, relationships: List[str] = None) -> Optional[ModelType]:
"""Find record by ID dengan optional eager loading."""
query = self.build_base_query().where(self.model.id == id)
if relationships:
for rel in relationships:
if hasattr(self.model, rel):
attr = getattr(self.model, rel)
if hasattr(attr.property, "collection_class"):
query = query.options(selectinload(attr))
else:
query = query.options(joinedload(attr))
result = await db.session.execute(query)
return result.scalar_one_or_none()
async def find_all(
self,
filters: list = [],
sort: list = [],
search: str = "",
group_by: str = None,
limit: int = 100,
offset: int = 0,
relationships: List[str] = None,
searchable_columns: List[str] = None,
) -> Tuple[List[ModelType], int]:
"""Optimized find_all method."""
query = self.build_base_query().filter(*filters)
# Optimized search
if search:
if searchable_columns:
search_conditions = [
cast(getattr(self.model, col), String).ilike(f"%{search}%")
for col in searchable_columns
if hasattr(self.model, col)
]
else:
search_conditions = [
cast(getattr(self.model, col), String).ilike(f"%{search}%")
for col in self.model.__table__.columns.keys()
if not col.startswith("_")
]
if search_conditions:
query = query.where(or_(*search_conditions))
if group_by:
query = query.group_by(getattr(self.model, group_by))
# Count query
count_query = select(func.count()).select_from(query.subquery())
total = await db.session.scalar(count_query)
# Data query
if sort:
query = query.order_by(*sort)
else:
query = query.order_by(self.model.id)
if relationships:
for rel in relationships:
if hasattr(self.model, rel):
attr = getattr(self.model, rel)
if hasattr(attr.property, "collection_class"):
query = query.options(selectinload(attr))
else:
query = query.options(joinedload(attr))
query = query.limit(limit).offset(offset)
result = await db.session.execute(query)
records = result.scalars().all()
return records, total
async def create(self, data: Dict[str, Any]) -> ModelType:
"""Create new record."""
new_record = self.model(**data)
db.session.add(new_record)
await db.session.commit()
await db.session.refresh(new_record)
return new_record
async def bulk_create(
self, data: List[Dict[str, Any]], batch_size: int = 1000, return_records: bool = False
) -> Optional[List[ModelType]]:
"""Bulk create dengan batching."""
if not data:
return [] if return_records else None
created_records = []
for i in range(0, len(data), batch_size):
batch = data[i : i + batch_size]
if return_records:
batch_records = [self.model(**item) for item in batch]
db.session.add_all(batch_records)
created_records.extend(batch_records)
else:
await db.session.execute(self.model.__table__.insert(), batch)
await db.session.commit()
if return_records:
for record in created_records:
await db.session.refresh(record)
return created_records
return None
async def update(self, id: UUID, data: Dict[str, Any], refresh: bool = True) -> Optional[ModelType]:
"""Update record dengan optimization."""
clean_data = {k: v for k, v in data.items() if v is not None}
if not clean_data:
return await self.find_by_id(id) if refresh else None
query = (
sqlalchemy_update(self.model)
.where(self.model.id == id)
.values(**clean_data)
.execution_options(synchronize_session="fetch")
)
result = await db.session.execute(query)
await db.session.commit()
if result.rowcount == 0:
return None
return await self.find_by_id(id) if refresh else None
async def delete(self, id: UUID) -> bool:
"""Delete record."""
query = sqlalchemy_delete(self.model).where(self.model.id == id)
result = await db.session.execute(query)
await db.session.commit()
return result.rowcount > 0
async def exists(self, id: UUID) -> bool:
"""Check if record exists."""
query = select(1).where(self.model.id == id)
if hasattr(self.model, "is_deleted"):
query = query.where(self.model.is_deleted.is_(False))
result = await db.session.scalar(query)
return result is not None

View File

@ -0,0 +1,143 @@
from app.models import CategoryModel, MapsetModel, ClassificationModel
from sqlalchemy import func, or_, cast, String, select
from sqlalchemy.orm import joinedload, selectinload
from fastapi_async_sqlalchemy import db
from typing import List, Tuple, Optional
from uuid import UUID
from . import BaseRepository
class CategoryRepository(BaseRepository[CategoryModel]):
def __init__(self, model):
super().__init__(model)
async def find_by_id(self, id: UUID, relationships: List[str] = None) -> Optional[CategoryModel]:
"""Find category by ID with mapset count."""
# Create subquery for mapset count
mapset_count_subquery = (
select(func.count(MapsetModel.id))
.join(ClassificationModel, MapsetModel.classification_id == ClassificationModel.id)
.where(MapsetModel.category_id == id)
.where(MapsetModel.is_deleted == False)
.where(MapsetModel.is_active == True)
.where(MapsetModel.status_validation == "approved")
.where(ClassificationModel.is_open == True)
.scalar_subquery()
)
# Build query with mapset count
query = (
select(self.model, mapset_count_subquery.label('mapset_count'))
.where(self.model.id == id)
)
if hasattr(self.model, "is_deleted"):
query = query.where(self.model.is_deleted.is_(False))
if relationships:
for rel in relationships:
if hasattr(self.model, rel):
attr = getattr(self.model, rel)
if hasattr(attr.property, "collection_class"):
query = query.options(selectinload(attr))
else:
query = query.options(joinedload(attr))
result = await db.session.execute(query)
row = result.first()
if row:
category = row[0]
category.count_mapset = row[1] if row[1] is not None else 0
return category
return None
async def find_all(
self,
filters: list = [],
sort: list = [],
search: str = "",
group_by: str = None,
limit: int = 100,
offset: int = 0,
relationships: List[str] = None,
searchable_columns: List[str] = None,
) -> Tuple[List[CategoryModel], int]:
"""Optimized find_all method with mapset count."""
# Create subquery for mapset count
mapset_count_subquery = (
select(
MapsetModel.category_id,
func.count(MapsetModel.id).label('mapset_count')
)
.join(ClassificationModel, MapsetModel.classification_id == ClassificationModel.id)
.where(MapsetModel.is_deleted == False)
.where(MapsetModel.is_active == True)
.where(MapsetModel.status_validation == "approved")
.where(ClassificationModel.is_open == True)
.group_by(MapsetModel.category_id)
.subquery()
)
# Build base query with mapset count
query = (
select(self.model, func.coalesce(mapset_count_subquery.c.mapset_count, 0).label('mapset_count'))
.outerjoin(mapset_count_subquery, self.model.id == mapset_count_subquery.c.category_id)
.filter(*filters)
)
# Optimized search
if search:
if searchable_columns:
search_conditions = [
cast(getattr(self.model, col), String).ilike(f"%{search}%")
for col in searchable_columns
if hasattr(self.model, col)
]
else:
search_conditions = [
cast(getattr(self.model, col), String).ilike(f"%{search}%")
for col in self.model.__table__.columns.keys()
if not col.startswith("_")
]
if search_conditions:
query = query.where(or_(*search_conditions))
if group_by:
query = query.group_by(getattr(self.model, group_by))
# Count query
count_query = select(func.count()).select_from(query.subquery())
total = await db.session.scalar(count_query)
# Data query
if sort:
query = query.order_by(*sort)
else:
query = query.order_by(self.model.order.asc())
if relationships:
for rel in relationships:
if hasattr(self.model, rel):
attr = getattr(self.model, rel)
if hasattr(attr.property, "collection_class"):
query = query.options(selectinload(attr))
else:
query = query.options(joinedload(attr))
query = query.limit(limit).offset(offset)
result = await db.session.execute(query)
# Extract records and set mapset_count
records = []
for row in result:
category = row[0]
category.count_mapset = row[1]
records.append(category)
return records, total

View File

@ -0,0 +1,8 @@
from app.models import ClassificationModel
from . import BaseRepository
class ClassificationRepository(BaseRepository[ClassificationModel]):
def __init__(self, model):
super().__init__(model)

View File

@ -0,0 +1,120 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi_async_sqlalchemy import db
from pytz import timezone
from sqlalchemy import select, update
from uuid6 import UUID
from app.core.config import settings
from app.models import CredentialModel
from . import BaseRepository
class CredentialRepository(BaseRepository[CredentialModel]):
def __init__(self, model):
super().__init__(model)
async def create(self, data: Dict[str, Any]):
credential = await super().create(data)
if credential.is_default:
await self.set_default(credential.id, credential.created_by)
return credential
async def get_by_type(self, credential_type: str, is_active: bool = True) -> List[CredentialModel]:
"""
Mendapatkan semua kredensial berdasarkan tipe.
Args:
credential_type: Tipe kredensial ('database', 'api', 'minio', dll)
is_active: Filter berdasarkan status aktif
Returns:
List dari credential models
"""
query = select(self.model).where(self.model.credential_type == credential_type)
if is_active is not None:
query = query.where(self.model.is_active == is_active)
query = query.order_by(self.model.created_at.desc())
result = await db.session.execute(query)
return result.scalars().all()
async def get_default_by_type(self, credential_type: str, is_active: bool = True) -> Optional[CredentialModel]:
"""
Mendapatkan kredensial default berdasarkan tipe.
Args:
credential_type: Tipe kredensial ('database', 'api', 'minio', dll)
is_active: Filter berdasarkan status aktif
Returns:
Credential model atau None jika tidak ditemukan
"""
query = select(self.model).where(self.model.credential_type == credential_type, self.model.is_default == True)
if is_active is not None:
query = query.where(self.model.is_active == is_active)
result = await db.session.execute(query)
return result.scalars().first()
async def set_default(self, credential_id: UUID, updated_by: UUID) -> bool:
"""
Set kredensial sebagai default untuk tipenya, dan unset default
untuk kredensial lain dengan tipe yang sama.
Args:
credential_id: ID kredensial yang akan dijadikan default
updated_by: ID user yang melakukan update
Returns:
Boolean yang menunjukkan keberhasilan operasi
"""
# Dapatkan kredensial yang akan diset sebagai default
cred = await self.find_by_id(credential_id)
if not cred:
return False
# Reset default flag untuk semua kredensial dengan tipe yang sama
reset_query = (
update(self.model)
.where(self.model.credential_type == cred.credential_type)
.values(is_default=False, updated_by=updated_by)
)
await db.session.execute(reset_query)
# Set sebagai default
set_query = (
update(self.model).where(self.model.id == credential_id).values(is_default=True, updated_by=updated_by)
)
await db.session.execute(set_query)
await db.session.commit()
return True
async def update_last_used(self, credential_id: UUID, user_id: UUID) -> bool:
"""
Update timestamp penggunaan terakhir.
Args:
db: Database session
credential_id: ID kredensial yang digunakan
user_id: ID user yang menggunakan
Returns:
Boolean yang menunjukkan keberhasilan operasi
"""
update_query = (
update(self.model)
.where(self.model.id == credential_id)
.values(last_used_at=datetime.now(timezone(settings.TIMEZONE)), last_used_by=user_id)
)
await db.session.execute(update_query)
await db.session.commit()
return True

View File

@ -0,0 +1,6 @@
from app.models.feedback_model import FeedbackModel
from app.repositories.base import BaseRepository
class FeedbackRepository(BaseRepository):
model = FeedbackModel

View File

@ -0,0 +1,16 @@
from fastapi_async_sqlalchemy import db
from sqlalchemy import select
from app.models import FileModel
from . import BaseRepository
class FileRepository(BaseRepository[FileModel]):
def __init__(self, model):
super().__init__(model)
async def find_by_user_id(self, user_id: int):
query = select(self.model.user_id).where(self.model.user_id == user_id)
result = await db.session.execute(query)
return result.scalars().all()

View File

@ -0,0 +1,39 @@
from fastapi_async_sqlalchemy import db
from sqlalchemy import select
from uuid6 import UUID
from app.models import MapAccessModel
from . import BaseRepository
class MapAccessRepository(BaseRepository[MapAccessModel]):
def __init__(self, model):
super().__init__(model)
async def find_by_mapset(self, mapset_id: UUID):
query = select(self.model).where(self.model.mapset_id == mapset_id)
result = await db.session.execute(query)
return result.scalars().all()
async def find_by_user(self, user_id: UUID):
query = select(self.model).where(self.model.user_id == user_id)
result = await db.session.execute(query)
return result.scalars().all()
async def find_by_organization(self, organization_id: UUID):
query = select(self.model).where(self.model.organization_id == organization_id)
result = await db.session.execute(query)
return result.scalars().all()
async def find_user_access_to_mapset(self, mapset_id: UUID, user_id: UUID):
query = select(self.model).where(self.model.mapset_id == mapset_id, self.model.user_id == user_id)
result = await db.session.execute(query)
return result.scalars().all()
async def find_organization_access_to_mapset(self, mapset_id: UUID, organization_id: UUID):
query = select(self.model).where(
self.model.mapset_id == mapset_id, self.model.organization_id == organization_id
)
result = await db.session.execute(query)
return result.scalars().all()

View File

@ -0,0 +1,8 @@
from app.models import MapProjectionSystemModel
from . import BaseRepository
class MapProjectionSystemRepository(BaseRepository[MapProjectionSystemModel]):
def __init__(self, model):
super().__init__(model)

View File

@ -0,0 +1,8 @@
from app.models import MapSourceModel
from . import BaseRepository
class MapSourceRepository(BaseRepository[MapSourceModel]):
def __init__(self, model):
super().__init__(model)

View File

@ -0,0 +1,28 @@
from typing import Any, Dict, List
from uuid import UUID
from fastapi_async_sqlalchemy import db
from sqlalchemy import delete
from app.models import SourceUsageModel
from . import BaseRepository
class SourceUsageRepository(BaseRepository[SourceUsageModel]):
def __init__(self, model):
super().__init__(model)
async def bulk_update(self, mapset_id: UUID, data: List[Dict[str, Any]]) -> None:
"""Update multiple records."""
try:
delete_query = delete(self.model).where(self.model.mapset_id == mapset_id)
await db.session.execute(delete_query)
new_records = [self.model(**item) for item in data]
db.session.add_all(new_records)
await db.session.commit()
except Exception as e:
await db.session.rollback()
raise e

View File

@ -0,0 +1,8 @@
from app.models import MapsetHistoryModel
from . import BaseRepository
class MapsetHistoryRepository(BaseRepository[MapsetHistoryModel]):
def __init__(self, model):
super().__init__(model)

View File

@ -0,0 +1,258 @@
from ast import Dict
from typing import List, Optional, Tuple
from uuid import UUID
from fastapi_async_sqlalchemy import db
from sqlalchemy import Integer, String, and_, cast, func, or_, select, update
from sqlalchemy.orm import selectinload
from app.models import (
ClassificationModel,
MapAccessModel,
MapsetModel,
OrganizationModel,
)
from app.schemas.user_schema import UserSchema
from . import BaseRepository
class MapsetRepository(BaseRepository[MapsetModel]):
def __init__(self, model):
super().__init__(model)
async def find_all(
self,
user: UserSchema = None,
filters: list = None,
sort: list = ...,
search: str = "",
group_by: str = None,
limit: int = 100,
offset: int = 0,
landing: bool = False,
) -> Tuple[List[MapsetModel], int]:
base_query = select(self.model).distinct()
base_query = base_query.join(ClassificationModel, self.model.classification_id == ClassificationModel.id)
if user and user.role.name not in {"administrator", "data_validator"}:
base_query = base_query.join(MapAccessModel, self.model.id == MapAccessModel.mapset_id, isouter=True)
if (user is None) or landing:
base_query = base_query.filter(ClassificationModel.is_open == True)
base_query = base_query.filter(self.model.is_active == True)
base_query = base_query.filter(self.model.status_validation == "approved")
elif user.role.name not in {"administrator", "data_validator"}:
base_query = base_query.filter(
or_(
# ClassificationModel.is_limited.is_(True),
# ClassificationModel.is_open.is_(True),
and_(
# ClassificationModel.is_secret.is_(True),
self.model.producer_id == user.organization.id,
),
and_(
# ClassificationModel.is_secret.is_(True),
MapAccessModel.organization_id == user.organization.id,
),
and_(
# ClassificationModel.is_secret.is_(True),
MapAccessModel.user_id == user.id,
),
)
)
if filters:
base_query = base_query.filter(*filters)
if search:
base_query = base_query.join(
OrganizationModel,
self.model.producer_id == OrganizationModel.id,
isouter=True,
).filter(
or_(
*[
cast(getattr(self.model, col), String).ilike(f"%{search}%")
for col in self.model.__table__.columns.keys()
],
OrganizationModel.name.ilike(f"%{search}%"),
)
)
if group_by:
base_query = base_query.group_by(getattr(self.model, group_by))
count_query = select(func.count()).select_from(base_query.subquery())
total = await db.session.scalar(count_query)
if not sort or sort is ...:
base_query = base_query.order_by(self.model.order.asc())
else:
base_query = base_query.order_by(*sort)
base_query = base_query.limit(limit).offset(offset)
result = await db.session.execute(base_query)
result = result.scalars().all()
return result, total
async def find_all_group_by_organization(
self,
user: Optional[UserSchema] = None,
mapset_filters: list = None,
organization_filters: list = None,
sort: list = None,
search: str = "",
limit: int = 100,
offset: int = 0,
) -> Tuple[List[Dict], int]:
mapset_filters = mapset_filters or []
organization_filters = organization_filters or []
sort = sort or [OrganizationModel.name.asc()]
if user is None:
base_mapset_query = (
select(self.model)
.join(
ClassificationModel,
self.model.classification_id == ClassificationModel.id,
)
.filter(ClassificationModel.is_open.is_(True))
)
elif user.role in {"administrator", "data-validator"}:
base_mapset_query = select(self.model)
else:
user_org_id = user.organization.id if user.organization else None
base_mapset_query = (
select(self.model)
.join(
ClassificationModel,
self.model.classification_id == ClassificationModel.id,
)
.outerjoin(MapAccessModel, self.model.id == MapAccessModel.mapset_id)
.filter(
or_(
ClassificationModel.is_open.is_(True),
ClassificationModel.is_limited.is_(True),
and_(
ClassificationModel.is_secret.is_(True),
self.model.producer_id == user.id,
),
and_(
ClassificationModel.is_secret.is_(True),
MapAccessModel.user_id == user.id,
),
and_(
ClassificationModel.is_secret.is_(True),
user_org_id is not None,
MapAccessModel.organization_id == user_org_id,
),
)
)
)
filtered_mapset_query = base_mapset_query
if mapset_filters:
filtered_mapset_query = filtered_mapset_query.filter(*mapset_filters)
if search:
search_filters = []
for col in self.model.__table__.columns.keys():
if hasattr(self.model, col):
search_filters.append(cast(getattr(self.model, col), String).ilike(f"%{search}%"))
if search_filters:
filtered_mapset_query = filtered_mapset_query.filter(or_(*search_filters))
producer_ids_subquery = select(self.model.producer_id).select_from(filtered_mapset_query.subquery()).distinct()
org_query = select(OrganizationModel).filter(OrganizationModel.id.in_(producer_ids_subquery))
if organization_filters:
org_query = org_query.filter(*organization_filters)
if search:
org_search_filters = []
for col in OrganizationModel.__table__.columns.keys():
if hasattr(OrganizationModel, col):
org_search_filters.append(cast(getattr(OrganizationModel, col), String).ilike(f"%{search}%"))
if org_search_filters:
org_query = org_query.filter(or_(*org_search_filters))
count_query = select(func.count()).select_from(
select(OrganizationModel.id).select_from(org_query.subquery()).distinct()
)
total = await db.session.scalar(count_query)
org_query = org_query.order_by(*sort)
if limit:
org_query = org_query.limit(limit)
if offset:
org_query = org_query.offset(offset)
org_result = await db.session.execute(org_query)
organizations = org_result.scalars().unique().all()
org_ids = [org.id for org in organizations]
if not org_ids:
return [], total
all_mapsets_query = filtered_mapset_query.filter(self.model.producer_id.in_(org_ids)).options(
selectinload(self.model.classification)
)
all_mapsets_result = await db.session.execute(all_mapsets_query)
all_mapsets = all_mapsets_result.scalars().unique().all()
mapsets_by_org = {}
for mapset in all_mapsets:
if mapset.producer_id not in mapsets_by_org:
mapsets_by_org[mapset.producer_id] = []
mapsets_by_org[mapset.producer_id].append(mapset)
result_data = []
for org in organizations:
org_mapsets = mapsets_by_org.get(org.id, [])
result_data.append(
{
"id": org.id,
"name": org.name,
"mapsets": org_mapsets,
"found": len(org_mapsets),
}
)
return result_data, total
async def bulk_update_activation(self, mapset_ids: List[UUID], is_active: bool) -> None:
for mapset_id in mapset_ids:
await db.session.execute(update(self.model).where(self.model.id == mapset_id).values(is_active=is_active))
await db.session.commit()
async def increment_view_count(self, mapset_id: UUID) -> None:
query = (
update(self.model)
.where(self.model.id == mapset_id)
.values(view_count=self.model.view_count + 1)
)
await db.session.execute(query)
await db.session.commit()
async def increment_download_count(self, mapset_id: UUID) -> None:
query = (
update(self.model)
.where(self.model.id == mapset_id)
.values(download_count=self.model.download_count + 1)
)
await db.session.execute(query)
await db.session.commit()

View File

@ -0,0 +1,19 @@
from typing import List
from uuid import UUID
from fastapi_async_sqlalchemy import db
from sqlalchemy import update
from app.models import NewsModel
from . import BaseRepository
class NewsRepository(BaseRepository[NewsModel]):
def __init__(self, model):
super().__init__(model)
async def bulk_update_activation(self, news_ids: List[UUID], is_active: bool) -> None:
for news_id in news_ids:
await db.session.execute(update(self.model).where(self.model.id == news_id).values(is_active=is_active))
await db.session.commit()

View File

@ -0,0 +1,318 @@
from typing import Any, Dict, List, Optional, Tuple, override
from uuid import UUID
from fastapi_async_sqlalchemy import db
from sqlalchemy import (
Integer,
Numeric,
String,
Unicode,
UnicodeText,
and_,
cast,
exists,
func,
or_,
select,
desc,
text,
update as sqlalchemy_update
)
from app.models.classification_model import ClassificationModel
from app.models.map_access_model import MapAccessModel
from app.models.mapset_model import MapsetModel
from app.models.organization_model import OrganizationModel
from app.schemas.user_schema import UserSchema
from . import BaseRepository
class OrganizationRepository(BaseRepository[OrganizationModel]):
def __init__(self, model, mapset_model: MapsetModel):
super().__init__(model)
self.mapset_model = mapset_model
async def flag_delete_organization(self, id):
return await self.flag_delete_organization(id)
async def find_by_name(self, name: str, sensitive: bool = False):
if not sensitive:
name = name.lower()
query = select(self.model)
if not sensitive:
query = query.where(self.model.name == name)
else:
query = query.where(self.model.name.ilike(f"%{name}%"))
result = await db.session.execute(query)
return result.scalar_one_or_none()
async def find_all(
self,
user: UserSchema | None,
filters: list,
sort: list | None = None,
search: str = "",
group_by: str = None,
limit: int = 100,
offset: int = 0,
landing: bool = False,
) -> Tuple[List[OrganizationModel], int]:
"""Find all records with pagination."""
if sort is None:
sort = []
mapset_count = func.count(self.mapset_model.id).label("count_mapset")
base_query = select(
self.model.id,
self.model.name,
self.model.description,
self.model.thumbnail,
self.model.address,
self.model.phone_number,
self.model.email,
self.model.website,
mapset_count,
self.model.is_active,
self.model.is_deleted,
self.model.created_at,
self.model.modified_at,
).select_from(self.model)
# Use simple outerjoin first, then apply WHERE conditions
# This ensures organizations without mapsets are still included
base_query = base_query.outerjoin(
self.mapset_model,
self.model.id == self.mapset_model.producer_id
)
base_query = base_query.outerjoin(
ClassificationModel,
self.mapset_model.classification_id == ClassificationModel.id,
)
# Apply mapset-level filters only to the mapset records, not to the organization join
mapset_conditions = [
or_(
self.mapset_model.id.is_(None), # Allow organizations without mapsets
and_(
self.mapset_model.is_active.is_(True),
self.mapset_model.is_deleted.is_(False),
)
)
]
# Add user-specific filters for mapsets
# When landing=True, count all mapsets without filtering by user organization
if (user is None) or landing:
mapset_conditions.append(
or_(
self.mapset_model.id.is_(None), # Organizations without mapsets
and_(
self.mapset_model.status_validation == "approved",
ClassificationModel.is_open.is_(True)
)
)
)
elif user.role not in {"administrator", "data_validator"}:
# When landing=False and user is not admin, filter by user organization
base_query = base_query.outerjoin(
MapAccessModel,
and_(
self.mapset_model.id == MapAccessModel.mapset_id,
or_(
MapAccessModel.organization_id == user.organization.id,
MapAccessModel.user_id == user.id,
),
),
)
mapset_conditions.append(
or_(
self.mapset_model.id.is_(None), # Organizations without mapsets
ClassificationModel.is_limited.is_(True),
ClassificationModel.is_open.is_(True),
and_(
ClassificationModel.is_secret.is_(True),
self.mapset_model.producer_id == user.organization.id,
),
)
)
# Apply all mapset conditions
base_query = base_query.where(and_(*mapset_conditions))
if hasattr(self.model, "is_deleted"):
base_query = base_query.where(self.model.is_deleted.is_(False))
if filters:
base_query = base_query.where(*filters)
if search:
search_filters = []
for col in self.model.__table__.columns.keys():
column = getattr(self.model, col)
if isinstance(column.type, (String, Unicode, UnicodeText)):
search_filters.append(column.ilike(f"%{search}%"))
elif isinstance(column.type, (Integer, Numeric)):
try:
num_val = float(search)
search_filters.append(cast(column, String) == str(num_val))
except (ValueError, TypeError):
pass
if search_filters:
base_query = base_query.where(or_(*search_filters))
group_columns = [self.model.id]
if group_by and hasattr(self.model, group_by):
group_col = getattr(self.model, group_by)
if group_col not in group_columns:
group_columns.append(group_col)
base_query = base_query.group_by(*group_columns)
count_query = select(func.count(self.model.id)).select_from(self.model)
if hasattr(self.model, "is_deleted"):
count_query = count_query.where(self.model.is_deleted.is_(False))
if filters:
count_query = count_query.where(*filters)
if search and search_filters:
count_query = count_query.where(or_(*search_filters))
# For count query, we don't need to filter organizations based on mapset availability
# This allows organizations with 0 mapsets to be included in the count
# The filtering logic should be the same as the main query structure
# but we don't need the mapset join conditions for counting organizations
pass
total = await db.session.scalar(count_query)
if sort:
base_query = base_query.order_by(*sort)
else:
base_query = base_query.order_by(desc(mapset_count))
base_query = base_query.limit(limit).offset(offset)
result = await db.session.execute(base_query)
items = result.mappings().all()
return items, total
@override
async def update(self, id: UUID, data: Dict[str, Any], refresh: bool = True) -> Optional[OrganizationModel]:
"""Update record with optimization."""
clean_data = {k: v for k, v in data.items() if v is not None}
if not clean_data:
return await self.find_by_id(None, id) if refresh else None
query = (
sqlalchemy_update(self.model)
.where(self.model.id == id)
.values(**clean_data)
.execution_options(synchronize_session="fetch")
)
result = await db.session.execute(query)
await db.session.commit()
if result.rowcount == 0:
return None
return await self.find_by_id(None, id) if refresh else None
@override
async def find_by_id(self, user: UserSchema | None, id: UUID) -> Optional[OrganizationModel]:
if user is None:
mapset_condition = and_(
self.mapset_model.is_active.is_(True),
self.mapset_model.is_deleted.is_(False),
self.mapset_model.status_validation == "approved",
self.mapset_model.producer_id == self.model.id,
)
mapset_filter = or_(
mapset_condition,
self.mapset_model.id.is_(None)
)
elif user.role in {"administrator", "data_validator"}:
mapset_condition = and_(
self.mapset_model.is_active.is_(True),
self.mapset_model.is_deleted.is_(False),
self.mapset_model.producer_id == self.model.id,
)
mapset_filter = or_(
mapset_condition,
self.mapset_model.id.is_(None)
)
else:
mapset_condition = and_(
or_(
ClassificationModel.is_limited.is_(True),
ClassificationModel.is_open.is_(True),
and_(
ClassificationModel.is_secret.is_(True),
self.mapset_model.producer_id == user.organization.id,
),
and_(
ClassificationModel.is_secret.is_(True),
MapAccessModel.organization_id == user.organization.id,
),
and_(
ClassificationModel.is_secret.is_(True),
MapAccessModel.user_id == user.id,
),
),
self.mapset_model.is_active.is_(True),
self.mapset_model.is_deleted.is_(False),
self.mapset_model.producer_id == self.model.id,
)
mapset_filter = or_(
mapset_condition,
self.mapset_model.id.is_(None)
)
query = (
select(
self.model.id,
self.model.name,
self.model.description,
self.model.thumbnail,
self.model.address,
self.model.phone_number,
self.model.email,
self.model.website,
func.count(self.mapset_model.id).label("count_mapset"),
self.model.is_active,
self.model.is_deleted,
self.model.created_at,
self.model.modified_at,
)
.outerjoin(self.mapset_model, self.model.id == self.mapset_model.producer_id)
.outerjoin(
ClassificationModel,
self.mapset_model.classification_id == ClassificationModel.id,
)
)
if user is not None and user.role not in {"administrator", "data_validator"}:
query = query.outerjoin(MapAccessModel, self.mapset_model.id == MapAccessModel.mapset_id)
if user is None or user.role not in {"administrator", "data_validator"}:
query = query.where(mapset_filter)
if hasattr(self.model, "is_deleted"):
query = query.filter(self.model.is_deleted.is_(False))
query = query.filter(self.model.id == id)
query = query.group_by(self.model.id)
result = await db.session.execute(query)
return result.mappings().one_or_none()

View File

@ -0,0 +1,8 @@
from app.models import RegionalModel
from . import BaseRepository
class RegionalRepository(BaseRepository[RegionalModel]):
def __init__(self, model):
super().__init__(model)

View File

@ -0,0 +1,24 @@
from typing import List
from fastapi_async_sqlalchemy import db
from sqlalchemy import select
from app.models import RoleModel
from . import BaseRepository
class RoleRepository(BaseRepository[RoleModel]):
def __init__(self, model):
super().__init__(model)
async def find_by_name(self, name: str) -> RoleModel:
"""Find record by name."""
query = select(self.model).where(self.model.name == name)
result = await db.session.execute(query)
return result.scalar_one_or_none()
async def get_list_by_names(self, name: List[str]) -> List[RoleModel]:
query = select(self.model).filter(self.model.name.in_(name))
result = await db.session.execute(query)
return result.scalars().all()

View File

@ -0,0 +1,39 @@
from datetime import datetime
from fastapi_async_sqlalchemy import db
from pytz import timezone
from sqlalchemy import select
from uuid6 import UUID
from app.core.config import settings
from app.models import RefreshTokenModel
from app.repositories import BaseRepository
class TokenRepository(BaseRepository[RefreshTokenModel]):
def __init__(self, model):
super().__init__(model)
async def find_valid_token(self, token: str, user_id: UUID):
query = select(self.model).where(
self.model.token == token,
self.model.user_id == str(user_id),
self.model.expires_at > datetime.now(timezone(settings.TIMEZONE)),
self.model.revoked == False,
)
result = await db.session.execute(query)
return result.scalars().first()
async def revoke_token(self, token: str):
token_obj = await self.find_by_token(token)
if token_obj:
token_obj.revoked = True
db.session.add(token_obj)
await db.session.commit()
return True
return False
async def find_by_token(self, token: str):
query = select(self.model).where(self.model.token == token)
result = await db.session.execute(query)
return result.scalars().first()

View File

@ -0,0 +1,41 @@
from typing import List
from fastapi_async_sqlalchemy import db
from sqlalchemy import select, update
from uuid6 import UUID
from app.models import UserModel
from . import BaseRepository
class UserRepository(BaseRepository[UserModel]):
def __init__(self, model):
super().__init__(model)
async def find_by_username(self, username: str) -> UserModel | None:
query = select(self.model).filter(self.model.username == username)
result = await db.session.execute(query)
return result.scalar_one_or_none()
async def find_by_email(self, email: str) -> UserModel | None:
query = select(self.model).filter(self.model.email == email)
result = await db.session.execute(query)
return result.scalar_one_or_none()
async def find_by_id(self, id: UUID) -> UserModel | None:
query = select(self.model).filter(self.model.id == id)
result = await db.session.execute(query)
return result.scalar_one_or_none()
async def find_all_ids(self, list_id: List) -> List[UserModel]:
query = select(self.model.id)
query = query.where(self.model.id.in_(list_id))
result = await db.session.execute(query)
return result.scalars().all()
async def bulk_update_activation(self, user_ids: List[UUID], is_active: bool) -> None:
for user_id in user_ids:
await db.session.execute(update(self.model).where(self.model.id == user_id).values(is_active=is_active))
await db.session.commit()

80
app/schemas/__init__.py Normal file
View File

@ -0,0 +1,80 @@
from .category_schema import CategoryCreateSchema, CategorySchema, CategoryUpdateSchema
from .classification_schema import (
ClassificationCreateSchema,
ClassificationSchema,
ClassificationUpdateSchema,
)
from .credential_schema import (
CredentialCreateSchema,
CredentialSchema,
CredentialUpdateSchema,
)
from .file_schema import FileSchema
from .map_access_schema import (
MapAccessCreateSchema,
MapAccessSchema,
MapAccessUpdateSchema,
)
from .map_projection_system_schema import (
MapProjectionSystemCreateSchema,
MapProjectionSystemSchema,
MapProjectionSystemUpdateSchema,
)
from .map_source_schema import (
MapSourceCreateSchema,
MapSourceSchema,
MapSourceUpdateSchema,
)
from .mapset_history_schema import MapsetHistoryCreateSchema, MapsetHistorySchema
from .mapset_schema import MapsetCreateSchema, MapsetSchema, MapsetUpdateSchema
from .news_schema import NewsCreateSchema, NewsSchema, NewsUpdateSchema
from .organization_schema import (
OrganizationCreateSchema,
OrganizationSchema,
OrganizationUpdateSchema,
)
from .regional_schema import RegionalCreateSchema, RegionalSchema, RegionalUpdateSchema
from .role_schema import RoleCreateSchema, RoleSchema, RoleUpdateSchema
from .user_schema import UserCreateSchema, UserSchema, UserUpdateSchema
__all__ = [
"OrganizationSchema",
"OrganizationCreateSchema",
"OrganizationUpdateSchema",
"UserSchema",
"UserCreateSchema",
"UserUpdateSchema",
"RoleSchema",
"RoleCreateSchema",
"RoleUpdateSchema",
"NewsSchema",
"NewsCreateSchema",
"NewsUpdateSchema",
"FileSchema",
"CredentialSchema",
"CredentialCreateSchema",
"CredentialUpdateSchema",
"MapsetSchema",
"MapsetCreateSchema",
"MapsetUpdateSchema",
"MapSourceSchema",
"MapSourceCreateSchema",
"MapSourceUpdateSchema",
"MapProjectionSystemSchema",
"MapProjectionSystemCreateSchema",
"MapProjectionSystemUpdateSchema",
"MapAccessSchema",
"MapAccessCreateSchema",
"MapAccessUpdateSchema",
"MapsetHistorySchema",
"MapsetHistoryCreateSchema",
"CategoryCreateSchema",
"CategorySchema",
"CategoryUpdateSchema",
"ClassificationSchema",
"ClassificationCreateSchema",
"ClassificationUpdateSchema",
"RegionalSchema",
"RegionalCreateSchema",
"RegionalUpdateSchema",
]

28
app/schemas/base.py Normal file
View File

@ -0,0 +1,28 @@
from typing import Generic, List, TypeVar
from pydantic import BaseModel, ConfigDict
from app.utils.helpers import orjson_dumps
T = TypeVar("T")
class BaseSchema(BaseModel):
"""Base Pydantic model with orjson configuration."""
model_config = ConfigDict(
populate_by_name=True,
from_attributes=True,
)
def model_dump_json(self, **kwargs):
"""Override default json serialization to use orjson."""
return orjson_dumps(self.model_dump(**kwargs))
class PaginatedResponse(BaseSchema, Generic[T]):
items: List[T]
total: int
limit: int
offset: int
has_more: bool

View File

@ -0,0 +1,30 @@
from typing import Optional
from pydantic import Field
from app.core.data_types import UUID7Field
from .base import BaseSchema
class CategorySchema(BaseSchema):
id: UUID7Field
name: str
description: Optional[str] = None
thumbnail: Optional[str] = None
count_mapset: int = 0
is_active: bool = True
class CategoryCreateSchema(BaseSchema):
name: str = Field(..., min_length=1)
description: Optional[str] = None
thumbnail: Optional[str] = None
is_active: bool = True
class CategoryUpdateSchema(BaseSchema):
name: Optional[str] = Field(None, min_length=1)
description: Optional[str] = None
thumbnail: Optional[str] = None
is_active: Optional[bool] = None

View File

@ -0,0 +1,29 @@
from typing import Optional
from pydantic import Field
from app.core.data_types import UUID7Field
from .base import BaseSchema
class ClassificationSchema(BaseSchema):
id: UUID7Field
name: str
is_open: bool
is_limited: bool
is_secret: bool
class ClassificationCreateSchema(BaseSchema):
name: str
is_open: bool
is_limited: bool
is_secret: bool
class ClassificationUpdateSchema(BaseSchema):
name: Optional[str] = Field(None)
is_open: Optional[bool] = Field(None)
is_limited: Optional[bool] = Field(None)
is_secret: Optional[bool] = Field(None)

View File

@ -0,0 +1,9 @@
from app.schemas.base import BaseSchema
class CountSchema(BaseSchema):
mapset_count: int
organization_count: int
visitor_count: int
metadata_count: int
download_count: int

View File

@ -0,0 +1,212 @@
from datetime import datetime
from typing import Any, Dict, Optional
from pydantic import Field, field_validator
from app.core.data_types import UUID7Field
from app.core.exceptions import UnprocessableEntity
from .base import BaseSchema
class CredentialBase(BaseSchema):
name: str = Field(..., description="Nama kredensial")
description: Optional[str] = Field(None, description="Deskripsi kredensial")
credential_type: str = Field(..., description="Tipe kredensial ('database', 'api', 'minio', dll)")
credential_metadata: Optional[Dict[str, Any]] = Field(
default={}, description="Metadata tidak sensitif (tidak dienkripsi)"
)
is_default: bool = Field(
default=False, description="Apakah kredensial ini digunakan sebagai default untuk tipenya"
)
class CredentialSchema(BaseSchema):
id: UUID7Field
name: str
description: Optional[str]
credential_type: str
credential_metadata: Optional[Dict[str, Any]]
is_default: bool
is_active: bool
created_by: UUID7Field
updated_by: Optional[UUID7Field]
created_at: datetime
updated_at: Optional[datetime]
last_used_at: Optional[datetime]
last_used_by: Optional[UUID7Field]
class CredentialWithSensitiveDataSchema(BaseSchema):
id: UUID7Field
name: str
description: Optional[str]
decrypted_data: Dict[str, Any]
credential_type: str
credential_metadata: Optional[Dict[str, Any]]
is_default: bool
is_active: bool
created_by: UUID7Field
updated_by: Optional[UUID7Field]
created_at: datetime
updated_at: Optional[datetime]
last_used_at: Optional[datetime]
last_used_by: Optional[UUID7Field]
class CredentialCreateSchema(CredentialBase):
name: str = Field(..., description="Nama kredensial")
description: Optional[str] = Field(None, description="Deskripsi kredensial")
credential_type: str = Field(..., description="Tipe kredensial ('database', 'api', 'minio', dll)")
credential_metadata: Optional[Dict[str, Any]] = Field(
default={}, description="Metadata tidak sensitif (tidak dienkripsi)"
)
is_default: bool = Field(
default=False, description="Apakah kredensial ini digunakan sebagai default untuk tipenya"
)
is_active: bool = Field(default=True, description="Status aktif kredensial")
sensitive_data: Dict[str, Any] = Field(..., description="Data sensitif yang akan dienkripsi")
@field_validator("credential_type")
@classmethod
def validate_credential_type(cls, v):
allowed_types = {"database", "api", "minio", "ssh", "smtp", "ftp", "geoserver", "geonetwork"}
if v not in allowed_types:
raise UnprocessableEntity(f'credential_type harus salah satu dari: {", ".join(allowed_types)}')
return v
@field_validator("sensitive_data")
@classmethod
def validate_sensitive_data(cls, v, values):
"""Validasi data sensitif berdasarkan tipe kredensial."""
credential_type = values.data.get("credential_type", "")
# Validasi untuk database
if credential_type == "database":
required_fields = {"host", "port", "username", "password", "database_name"}
missing = required_fields - set(v.keys())
if missing:
raise UnprocessableEntity(f"Missing required fields for database: {', '.join(missing)}")
# Validasi untuk MinIO
elif credential_type == "minio":
required_fields = {"endpoint", "access_key", "secret_key", "secure", "bucket_name"}
missing = required_fields - set(v.keys())
if missing:
raise UnprocessableEntity(f"Missing required fields for minio: {', '.join(missing)}")
# Validasi untuk API
elif credential_type == "api":
required_fields = {"base_url", "api_key"}
missing = required_fields - set(v.keys())
if missing:
raise UnprocessableEntity(f"Missing required fields for api: {', '.join(missing)}")
# Validasi untuk SSH
elif credential_type == "ssh":
if not ("password" in v or "private_key" in v):
raise UnprocessableEntity("Either 'password' or 'private_key' is required for SSH credentials")
required_fields = {"host", "port", "username"}
missing = required_fields - set(v.keys())
if missing:
raise UnprocessableEntity(f"Missing required fields for ssh: {', '.join(missing)}")
# Validasi untuk SMTP
elif credential_type == "smtp":
required_fields = {"host", "port", "username", "password", "use_tls"}
missing = required_fields - set(v.keys())
if missing:
raise UnprocessableEntity(f"Missing required fields for smtp: {', '.join(missing)}")
# Validasi untuk FTP
elif credential_type == "ftp":
required_fields = {"host", "port", "username", "password"}
missing = required_fields - set(v.keys())
if missing:
raise UnprocessableEntity(f"Missing required fields for ftp: {', '.join(missing)}")
elif credential_type == "server":
required_fields = {"host", "port", "username", "password"}
missing = required_fields - set(v.keys())
if missing:
raise UnprocessableEntity(f"Missing required fields for server: {', '.join(missing)}")
return v
class CredentialUpdateSchema(BaseSchema):
name: Optional[str] = Field(None, description="Nama kredensial")
description: Optional[str] = Field(None, description="Deskripsi kredensial")
credential_type: Optional[str] = Field(None, description="Tipe kredensial ('database', 'api', 'minio', dll)")
credential_metadata: Optional[Dict[str, Any]] = Field(
default={}, description="Metadata tidak sensitif (tidak dienkripsi)"
)
is_default: Optional[bool] = Field(
default=False, description="Apakah kredensial ini digunakan sebagai default untuk tipenya"
)
is_active: Optional[bool] = Field(default=True, description="Status aktif kredensial")
sensitive_data: Optional[Dict[str, Any]] = Field(None, description="Data sensitif yang akan dienkripsi")
@field_validator("credential_type")
@classmethod
def validate_credential_type(cls, v):
allowed_types = {"database", "api", "minio", "ssh", "smtp", "ftp"}
if v not in allowed_types:
raise UnprocessableEntity(f'credential_type harus salah satu dari: {", ".join(allowed_types)}')
return v
@field_validator("sensitive_data")
@classmethod
def validate_sensitive_data(cls, v, values):
"""Validasi data sensitif berdasarkan tipe kredensial."""
credential_type = values.data.get("credential_type", "")
# Validasi untuk database
if credential_type == "database":
required_fields = {"host", "port", "username", "password", "database_name"}
missing = required_fields - set(v.keys())
if missing:
raise UnprocessableEntity(f"Missing required fields for database: {', '.join(missing)}")
# Validasi untuk MinIO
elif credential_type == "minio":
required_fields = {"endpoint", "access_key", "secret_key", "secure", "bucket_name"}
missing = required_fields - set(v.keys())
if missing:
raise UnprocessableEntity(f"Missing required fields for minio: {', '.join(missing)}")
# Validasi untuk API
elif credential_type == "api":
required_fields = {"base_url", "api_key"}
missing = required_fields - set(v.keys())
if missing:
raise UnprocessableEntity(f"Missing required fields for api: {', '.join(missing)}")
# Validasi untuk SSH
elif credential_type == "ssh":
if not ("password" in v or "private_key" in v):
raise UnprocessableEntity("Either 'password' or 'private_key' is required for SSH credentials")
required_fields = {"host", "port", "username"}
missing = required_fields - set(v.keys())
if missing:
raise UnprocessableEntity(f"Missing required fields for ssh: {', '.join(missing)}")
# Validasi untuk SMTP
elif credential_type == "smtp":
required_fields = {"host", "port", "username", "password", "use_tls"}
missing = required_fields - set(v.keys())
if missing:
raise UnprocessableEntity(f"Missing required fields for smtp: {', '.join(missing)}")
# Validasi untuk FTP
elif credential_type == "ftp":
required_fields = {"host", "port", "username", "password"}
missing
elif credential_type == "server":
required_fields = {"host", "port", "username", "password"}
missing = required_fields - set(v.keys())
if missing:
raise UnprocessableEntity(f"Missing required fields for server: {', '.join(missing)}")

View File

@ -0,0 +1,5 @@
from .base import BaseSchema
class ErrorResponse(BaseSchema):
message: str

View File

@ -0,0 +1,34 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class FeedbackBase(BaseModel):
score: int
tujuan_tercapai: Optional[bool] = True
tujuan_ditemukan: Optional[bool] = True
tujuan: Optional[str] = None
sektor: Optional[str] = None
email: Optional[str] = None
saran: Optional[str] = None
source_url: Optional[str] = None
source_access: Optional[str] = None
notes: Optional[str] = None
gender: Optional[int] = None
class FeedbackCreateSchema(FeedbackBase):
pass
class FeedbackUpdateSchema(FeedbackBase):
score: Optional[int] = None
class FeedbackSchema(FeedbackBase):
id: int
datetime: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,33 @@
from datetime import datetime
from typing import Optional
from pydantic import Field
from app.core.data_types import UUID7Field
from .base import BaseSchema
from .user_schema import UserSchema
class FileSchema(BaseSchema):
id: UUID7Field
object_name: str
uploaded_by: UserSchema
created_at: datetime
modified_at: Optional[datetime] = None
class FileCreateSchema(BaseSchema):
filename: str
content_type: str
size: int
description: Optional[str] = None
url: str
class FileUpdateSchema(BaseSchema):
filename: Optional[str] = Field(None, title="File Name")
content_type: Optional[str] = Field(None, title="Content Type")
size: Optional[int] = Field(None, title="File Size")
description: Optional[str] = Field(None, title="File Description")
url: Optional[str] = Field(None, title="File URL")

View File

@ -0,0 +1,30 @@
from typing import Optional
from app.core.data_types import UUID7Field
from .base import BaseSchema
class MapAccessSchema(BaseSchema):
id: UUID7Field
mapset_id: UUID7Field
user_id: Optional[UUID7Field] = None
organization_id: Optional[UUID7Field] = None
can_read: bool
can_write: bool
can_delete: bool
class MapAccessCreateSchema(BaseSchema):
mapset_id: UUID7Field
user_id: Optional[UUID7Field] = None
organization_id: Optional[UUID7Field] = None
can_read: bool
can_write: bool
can_delete: bool
class MapAccessUpdateSchema(BaseSchema):
can_read: Optional[bool] = None
can_write: Optional[bool] = None
can_delete: Optional[bool] = None

View File

@ -0,0 +1,20 @@
from typing import Optional
from pydantic import Field
from app.core.data_types import UUID7Field
from .base import BaseSchema
class MapProjectionSystemSchema(BaseSchema):
id: UUID7Field
name: str
class MapProjectionSystemCreateSchema(BaseSchema):
name: str = Field(..., min_length=1, max_length=50)
class MapProjectionSystemUpdateSchema(BaseSchema):
name: Optional[str] = Field(None, min_length=1, max_length=50)

View File

@ -0,0 +1,38 @@
from datetime import datetime
from typing import Optional
from pydantic import Field
from app.core.data_types import UUID7Field
from app.schemas import CredentialSchema
from .base import BaseSchema
class MapSourceSchema(BaseSchema):
id: UUID7Field
name: str
description: Optional[str]
credential: CredentialSchema
url: Optional[str]
is_active: bool
is_deleted: bool
created_at: datetime
updated_at: Optional[datetime]
class MapSourceCreateSchema(BaseSchema):
name: str = Field(..., min_length=1, max_length=50)
description: Optional[str] = Field(None)
url: Optional[str]
credential_id: UUID7Field
is_active: bool = True
class MapSourceUpdateSchema(BaseSchema):
name: Optional[str] = Field(None, min_length=1, max_length=50)
description: Optional[str] = Field(None)
url: Optional[str] = Field(None)
credential_id: Optional[UUID7Field]
is_active: Optional[bool] = None
is_deleted: Optional[bool] = None

View File

@ -0,0 +1,24 @@
from datetime import datetime
from typing import Optional
from pydantic import Field
from app.core.data_types import UUID7Field
from app.schemas.user_schema import UserSchema
from .base import BaseSchema
class MapsetHistorySchema(BaseSchema):
id: UUID7Field
mapset_id: UUID7Field
validation_type: str
notes: Optional[str]
timestamp: datetime
user_info: UserSchema = Field(alias="user")
class MapsetHistoryCreateSchema(BaseSchema):
mapset_id: UUID7Field
validation_type: str = Field(..., min_length=1)
notes: Optional[str] = None

View File

@ -0,0 +1,98 @@
from datetime import datetime
from typing import List, Optional
from pydantic import Field
from app.core.data_types import UUID7Field
from app.schemas.category_schema import CategorySchema
from app.schemas.classification_schema import ClassificationSchema
from app.schemas.map_projection_system_schema import MapProjectionSystemSchema
from app.schemas.map_source_schema import MapSourceSchema
from app.schemas.organization_schema import OrganizationWithMapsetSchema
from app.schemas.regional_schema import RegionalSchema
from .base import BaseSchema
class MapsetSchema(BaseSchema):
id: UUID7Field
name: str
description: str
scale: Optional[str]
layer_url: Optional[str]
metadata_url: Optional[str]
status_validation: Optional[str]
classification: str
data_status: str
data_update_period: Optional[str]
data_version: Optional[str]
coverage_level: Optional[str]
coverage_area: Optional[str]
layer_type: Optional[str]
category: CategorySchema
projection_system: MapProjectionSystemSchema
producer: OrganizationWithMapsetSchema
regional: Optional[RegionalSchema]
sources: Optional[List[MapSourceSchema]] = Field([])
classification: ClassificationSchema
view_count: int
download_count: int
is_popular: bool
is_active: bool
created_at: datetime
updated_at: datetime
class MapsetByOrganizationSchema(BaseSchema):
id: UUID7Field
name: str
found: int
mapsets: List[MapsetSchema]
class MapsetCreateSchema(BaseSchema):
name: str
description: Optional[str] = Field(None)
scale: Optional[str] = Field(None)
layer_url: str
metadata_url: Optional[str] = Field(None)
status_validation: str
layer_type: Optional[str] = Field(None)
projection_system_id: UUID7Field
category_id: UUID7Field
classification_id: UUID7Field
source_id: Optional[List[UUID7Field]] = Field(None)
regional_id: UUID7Field
producer_id: UUID7Field
data_status: str
data_update_period: Optional[str] = Field(default=None)
data_version: Optional[str] = Field(default=None)
coverage_level: Optional[str] = Field(default=None)
coverage_area: Optional[str] = Field(default=None)
is_popular: bool = Field(default=False)
is_active: bool = Field(default=True)
notes: Optional[str] = Field(None)
class MapsetUpdateSchema(BaseSchema):
name: Optional[str] = Field(None)
description: Optional[str] = Field(None)
scale: Optional[str] = Field(None)
layer_url: Optional[str] = Field(None)
metadata_url: Optional[str] = Field(None)
status_validation: Optional[str] = Field(None)
layer_type: Optional[str] = Field(None)
projection_system_id: Optional[UUID7Field] = Field(None)
category_id: Optional[UUID7Field] = Field(None)
classification_id: Optional[UUID7Field] = Field(None)
source_id: Optional[List[UUID7Field]] = Field(None)
regional_id: Optional[UUID7Field] = Field(None)
producer_id: Optional[UUID7Field] = Field(None)
data_status: Optional[str] = Field(None)
data_update_period: Optional[str] = Field(None)
data_version: Optional[str] = Field(None)
coverage_level: Optional[str] = Field(None)
coverage_area: Optional[str] = Field(None)
is_popular: Optional[bool] = Field(None)
is_active: Optional[bool] = Field(None)
notes: Optional[str] = Field(None)

View File

@ -0,0 +1,29 @@
from typing import Optional
from pydantic import Field
from app.core.data_types import UUID7Field
from .base import BaseSchema
class NewsSchema(BaseSchema):
id: UUID7Field
name: str
description: Optional[str] = None
thumbnail: Optional[str] = None
is_active: bool = True
class NewsCreateSchema(BaseSchema):
name: str = Field(..., min_length=1)
description: Optional[str] = None
thumbnail: Optional[str] = None
is_active: bool = True
class NewsUpdateSchema(BaseSchema):
name: Optional[str] = Field(None, min_length=1)
description: Optional[str] = None
thumbnail: Optional[str] = None
is_active: Optional[bool] = None

View File

@ -0,0 +1,57 @@
from datetime import datetime
from typing import Optional
from pydantic import EmailStr, Field
from app.core.data_types import UUID7Field
from .base import BaseSchema
class OrganizationSchema(BaseSchema):
id: UUID7Field
name: str
description: Optional[str]
thumbnail: Optional[str]
address: Optional[str]
phone_number: Optional[str]
email: Optional[EmailStr]
website: Optional[str]
count_mapset: int = 0
is_active: bool
created_at: datetime
modified_at: Optional[datetime]
class OrganizationWithMapsetSchema(BaseSchema):
id: UUID7Field
name: str
description: Optional[str]
thumbnail: Optional[str]
address: Optional[str]
phone_number: Optional[str]
email: Optional[EmailStr]
website: Optional[str]
class OrganizationCreateSchema(BaseSchema):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(None, min_length=1, max_length=500)
thumbnail: Optional[str] = Field(None, min_length=1, max_length=255)
address: Optional[str] = Field(None, min_length=1, max_length=255)
phone_number: Optional[str] = Field(None, min_length=1, max_length=15)
email: Optional[EmailStr] = Field(None, max_length=100)
website: Optional[str] = Field(None, min_length=1, max_length=100)
is_active: Optional[bool] = Field(True)
class OrganizationUpdateSchema(BaseSchema):
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, min_length=1, max_length=500)
thumbnail: Optional[str] = Field(None, min_length=1, max_length=500)
phone_number: Optional[str] = Field(None, min_length=1, max_length=15)
address: Optional[str] = Field(None, min_length=1, max_length=500)
email: Optional[EmailStr] = Field(None)
website: Optional[str] = Field(None, min_length=1, max_length=100)
is_active: Optional[bool] = Field(None)
is_deleted: Optional[bool] = Field(None)

View File

@ -0,0 +1,32 @@
from typing import Optional
from pydantic import Field
from app.core.data_types import UUID7Field
from .base import BaseSchema
class RegionalSchema(BaseSchema):
id: UUID7Field
code: str
name: str
description: Optional[str] = None
thumbnail: Optional[str] = None
is_active: bool = True
class RegionalCreateSchema(BaseSchema):
code: str = Field(..., min_length=1, max_length=10)
name: str = Field(..., min_length=1, max_length=50)
description: Optional[str] = None
thumbnail: Optional[str] = None
is_active: bool = True
class RegionalUpdateSchema(BaseSchema):
code: Optional[str] = Field(None, min_length=1, max_length=10)
name: Optional[str] = Field(None, min_length=1, max_length=50)
description: Optional[str] = None
thumbnail: Optional[str] = None
is_active: Optional[bool] = None

View File

@ -0,0 +1,55 @@
from typing import Optional
from pydantic import Field, field_validator
from app.core.data_types import UUID7Field
from app.core.exceptions import UnprocessableEntity
from .base import BaseSchema
class RoleSchema(BaseSchema):
id: UUID7Field
name: str = Field(..., min_length=1, max_length=50)
description: Optional[str] = Field(None)
is_active: bool = True
class RoleCreateSchema(BaseSchema):
name: str = Field(..., min_length=1, max_length=50)
description: Optional[str] = Field(None)
is_active: bool = True
@field_validator("name")
@classmethod
def validate_name(cls, value):
if value is None:
return value
valid_role = ["administrator", "data_validator", "data_manager", "data_viewer"]
if value not in valid_role:
raise UnprocessableEntity(
f"Role name must be one of the following: administrator, {', '.join(valid_role)}"
)
return value
class RoleUpdateSchema(BaseSchema):
name: Optional[str] = Field(None, min_length=1, max_length=50)
description: Optional[str] = Field(None)
is_active: Optional[bool] = None
@field_validator("name")
@classmethod
def validate_name(cls, value):
if value is None:
return value
valid_role = ["administrator", "data-validator", "data-manager", "data-observer"]
if value not in valid_role:
raise UnprocessableEntity(
f"Role name must be one of the following: administrator, {', '.join(valid_role)}"
)
return value

View File

@ -0,0 +1,21 @@
from datetime import datetime
from typing import Optional
from .base import BaseSchema
class Token(BaseSchema):
access_token: str
refresh_token: str
expires_at: float
token_type: str = "bearer"
class TokenPayload(BaseSchema):
sub: Optional[str] = None
exp: Optional[datetime] = None
type: Optional[str] = None
class RefreshTokenSchema(BaseSchema):
refresh_token: str

138
app/schemas/user_schema.py Normal file
View File

@ -0,0 +1,138 @@
import re
from typing import Optional
from pydantic import EmailStr, Field, field_validator
from app.core.data_types import UUID7Field
from app.core.exceptions import UnprocessableEntity
from .base import BaseSchema
from .organization_schema import OrganizationWithMapsetSchema
from .role_schema import RoleSchema
class UserSchema(BaseSchema):
id: UUID7Field
name: str
email: EmailStr
profile_picture: Optional[str] = None
username: str
position: Optional[str] = None
role: RoleSchema | None
employee_id: Optional[str] = None
organization: OrganizationWithMapsetSchema
is_active: bool = True
class UserCreateSchema(BaseSchema):
name: str = Field(..., min_length=4, max_length=100)
email: EmailStr
profile_picture: Optional[str] = Field(None)
username: str = Field(None, min_length=3, max_length=30, pattern=r"^[a-zA-Z0-9_]+$")
password: str = Field(..., min_length=8, max_length=128)
position: Optional[str] = Field(None)
role_id: UUID7Field
employee_id: Optional[str] = None
organization_id: UUID7Field
is_active: bool = True
@field_validator("password")
@classmethod
def validate_password(cls, value):
if value is None:
return value
has_letter = any(c.isalpha() for c in value)
has_digit = any(c.isdigit() for c in value)
has_special = any(c in "@$!%*#?&" for c in value)
if not (has_letter and has_digit and has_special):
raise UnprocessableEntity(
"Password must be at least 8 characters long and contain at least one letter, one number, and one special character"
)
return value
@field_validator("username")
@classmethod
def validate_username(cls, value):
if value is None:
return value
if not re.match(r"^[a-zA-Z0-9_]+$", value):
raise UnprocessableEntity("Username can only contain letters, numbers, and underscores")
return value
@field_validator("email")
@classmethod
def validate_email_domain(cls, value):
if value is None:
return value
# Validasi tambahan untuk domain email jika diperlukan
value.split("@")[1]
valid_domains = ["gmail.com", "yahoo.com", "hotmail.com", "company.com"] # Sesuaikan dengan kebutuhan
# Hapus validasi ini jika tidak diperlukan atau sesuaikan dengan kebutuhan
# if domain not in valid_domains:
# raise UnprocessableEntity(f'Domain email tidak valid. Domain yang diizinkan: {", ".join(valid_domains)}')
return value
class UserUpdateSchema(BaseSchema):
name: Optional[str] = Field(None, min_length=2, max_length=100)
email: Optional[EmailStr] = None
profile_picture: Optional[str] = Field(None, max_length=255)
username: Optional[str] = Field(None, min_length=3, max_length=30, pattern=r"^[a-zA-Z0-9_]+$")
password: Optional[str] = Field(None, min_length=8, max_length=128)
position: Optional[str] = Field(None)
role_id: Optional[UUID7Field] = Field(None)
employee_id: Optional[str] = Field(None, max_length=50)
organization_id: Optional[UUID7Field] = Field(None)
is_active: Optional[bool] = None
@field_validator("password")
@classmethod
def validate_password(cls, value):
if value is None:
return value
has_letter = any(c.isalpha() for c in value)
has_digit = any(c.isdigit() for c in value)
has_special = any(c in "@$!%*#?&" for c in value)
if not (has_letter and has_digit and has_special):
raise UnprocessableEntity(
"Password must be at least 8 characters long and contain at least one letter, one number, and one special character"
)
return value
@field_validator("username")
@classmethod
def validate_username(cls, value):
if value is None:
return value
if not re.match(r"^[a-zA-Z0-9_]+$", value):
raise UnprocessableEntity("Username can only contain letters, numbers, and underscores")
return value
@field_validator("email")
@classmethod
def validate_email_domain(cls, value):
if value is None:
return value
# Validasi tambahan untuk domain email jika diperlukan
value.split("@")[1]
valid_domains = ["gmail.com", "yahoo.com", "hotmail.com", "company.com"] # Sesuaikan dengan kebutuhan
# Hapus validasi ini jika tidak diperlukan atau sesuaikan dengan kebutuhan
# if domain not in valid_domains:
# raise UnprocessableEntity(f'Domain email tidak valid. Domain yang diizinkan: {", ".join(valid_domains)}')
return value

37
app/services/__init__.py Normal file
View File

@ -0,0 +1,37 @@
from .auth_service import AuthService
from .base import BaseService
from .category_service import CategoryService
from .classification_service import ClassificationService
from .credential_service import CredentialService
from .feedback_service import FeedbackService
from .file_service import FileService
from .map_projection_system_service import MapProjectionSystemService
from .map_source_service import MapSourceService
from .mapset_history_service import MapsetHistoryService
from .mapset_service import MapsetService
from .news_service import NewsService
from .organization_service import OrganizationService
from .regional_service import RegionalService
from .role_service import RoleService
from .user_service import UserService
from .count_service import CountService
__all__ = [
"BaseService",
"OrganizationService",
"RoleService",
"UserService",
"AuthService",
"NewsService",
"FileService",
"CredentialService",
"FeedbackService",
"MapSourceService",
"MapProjectionSystemService",
"CategoryService",
"ClassificationService",
"RegionalService",
"MapsetService",
"MapsetHistoryService",
"CountService",
]

Some files were not shown because too many files have changed in this diff Show More