chore: Resolve merge conflicts - update actions/cache to v5 on rebased workflows

Co-authored-by: furyhawk <831682+furyhawk@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-05-11 09:25:34 +00:00
committed by GitHub
co-authored by furyhawk
30 changed files with 2080 additions and 1858 deletions
+55
View File
@@ -0,0 +1,55 @@
---
description: "Use when: designing or modifying SQLAlchemy models, creating database migrations, altering schema, managing relationships, or working with Alembic"
name: "Database & Models Specialist"
tools: [read, edit, search, execute]
user-invocable: true
---
You are a database and models specialist for the Listen project. Your role is to design, create, and modify SQLAlchemy models, manage Alembic migrations, and maintain database schema integrity following project conventions.
## Core Conventions
- **SQLAlchemy 2.0 async patterns**: Use `Mapped`, `mapped_column()`, and declarative syntax
- **Base class inheritance**: All models inherit from `Base` which provides `create_time` and `update_time` timestamps automatically
- **Naming**: Table names are lowercase singular (e.g. `user_account`, `refresh_token`, `pet`)
- **Primary keys**: Typically `BigInteger` for auto-incrementing, `Uuid` for user-facing IDs (stored as strings)
- **Relationships**: Use `relationship()` with `back_populates` for bidirectional relationships, include `ondelete="CASCADE"` on foreign keys
- **Alembic automation**: After model changes, run `alembic revision --autogenerate -m "description"` then `alembic upgrade head`
- **Testing**: Changes to models require corresponding migration tests; minimum 92% coverage
## Responsibilities
1. **Model Design**: Create well-structured SQLAlchemy models with proper types, constraints, and relationships
2. **Migrations**: Generate, review, and apply Alembic migrations; ensure bidirectional relationships in models
3. **Schema Changes**: Modify existing models while maintaining backward compatibility where appropriate
4. **Relationship Management**: Set up proper foreign key constraints and ORM relationships
5. **Type Safety**: Use SQLAlchemy 2.0 type hints (`Mapped[T]`, `type[T]`) consistently
## Constraints
- DO NOT use deprecated SQLAlchemy 1.x patterns (`Column()`, `ForeignKey()` without relationship setup)
- DO NOT create models without proper `__tablename__` definition
- DO NOT forget bidirectional relationships when creating foreign key relationships
- DO NOT modify migration files manually—regenerate them via `alembic revision --autogenerate`
- DO NOT skip test coverage when adding new models or relationships
## Common Tasks
- **Add a new model**: Create model in `app/models.py`, run Alembic autogenerate, apply migration
- **Add a relationship**: Use `relationship()` with `back_populates` on both sides
- **Modify field type or constraint**: Update model, autogenerate migration, apply it
- **Handle cascading deletes**: Set `ondelete="CASCADE"` on ForeignKey for dependent data
## Tools to Use
- **read**: Review `app/models.py`, `alembic/versions/`, database schema
- **edit**: Modify models, apply migrations
- **search**: Find model references, relationship usage patterns
- **execute**: Run Alembic commands (`alembic revision`, `alembic upgrade`)
## Output Format
For model changes, always provide:
1. Model definition updated (with full class shown)
2. Alembic migration command to run
3. Brief explanation of schema change
+3 -3
View File
@@ -24,18 +24,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
+14 -15
View File
@@ -22,36 +22,35 @@ jobs:
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13.1"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: false
virtualenvs-path: /opt/venv
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Load cached venv
id: cached-poetry-dependencies
id: cached-uv-dependencies
uses: actions/cache@v5
with:
path: /opt/venv
key: venv-${{ runner.os }}-python-3.13.1-${{ hashFiles('poetry.lock') }}
path: .venv
key: venv-${{ runner.os }}-python-3.13.1-${{ hashFiles('pyproject.toml', 'uv.lock') }}
- name: Install dependencies and actiavte virtualenv
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
- name: Install dependencies
run: |
poetry install --no-interaction --no-root
uv sync --all-extras
- name: Run tests
env:
SECURITY__JWT_SECRET_KEY: very-not-secret
SECURITY__BACKEND_CORS_ORIGINS: "[]"
SECURITY__ALLOWED_HOSTS: '["localhost", "127.0.0.1"]'
DATABASE__HOSTNAME: localhost
DATABASE__USERNAME: postgres
DATABASE__PASSWORD: postgres
DATABASE__DB: postgres
run: |
poetry run pytest
uv run pytest
+10 -21
View File
@@ -14,38 +14,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13.1"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: false
virtualenvs-path: /opt/venv
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Load cached venv
id: cached-poetry-dependencies
id: cached-uv-dependencies
uses: actions/cache@v5
with:
path: /opt/venv
key: venv-${{ runner.os }}-python-3.13.1-${{ hashFiles('poetry.lock') }}
path: .venv
key: venv-${{ runner.os }}-python-3.13.1-${{ hashFiles('pyproject.toml', 'uv.lock') }}
- name: Install dependencies and actiavte virtualenv
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
- name: Install dependencies
run: |
poetry install --no-interaction --no-root
uv sync --all-extras
- name: Run ${{ matrix.check }}
run: |
poetry run ${{ matrix.check }} .
uv run ${{ matrix.check }} .
+12
View File
@@ -0,0 +1,12 @@
{
"python.testing.pytestArgs": [
"app"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"chat.tools.terminal.autoApprove": {
"pytest": true,
"uv": true,
"true": true
}
}
+34
View File
@@ -0,0 +1,34 @@
# AGENTS.md — Guidance for AI coding agents
Purpose: short, actionable guidance for coding agents working in this repository.
Quick facts
- **Python:** >= 3.13 (see [pyproject.toml](pyproject.toml)).
- **Run app:** `uvicorn app.main:app --reload` (see [app/main.py](app/main.py)).
- **Docker:** `docker-compose up -d` (see [docker-compose.yml](docker-compose.yml)).
- **Migrations:** `alembic upgrade head` (see [alembic.ini](alembic.ini) and `alembic/`).
- **Tests:** `pytest` (configuration in [pyproject.toml](pyproject.toml); tests under `app/tests`).
What agents should know (concise)
- Use the project's README for onboarding steps and environment setup: [README.md](README.md).
- Dependencies are declared in `pyproject.toml`; dev extras include `pytest`, `pytest-asyncio`, `pytest-xdist`, and `ruff`.
- Tests create temporary databases (see `app/tests/conftest.py`) and rely on `docker-compose` for a PostgreSQL service when needed.
- The app entrypoint is `app.main:app`; routers are in `app/api`.
- Alembic is configured for async SQLAlchemy—use the repository's alembic folder for migrations.
Agent behavior recommendations
- Prefer linking to existing docs instead of copying long sections; follow "link, don't embed".
- When suggesting code edits, reference the exact file(s) and include minimal, focused patches.
- Run tests locally with `pytest -q` and use `-k` to target changed areas during iteration.
- Respect the project's Python version and typing/mypy strictness.
Next customizations to consider
- Add a small `.github/copilot-instructions.md` if repository owners want per-repo agent role presets.
- Create a `/.vscode` or `devcontainer` quickstart note for reproducible developer environments.
Relevant files
- [README.md](README.md)
- [pyproject.toml](pyproject.toml)
- [app/main.py](app/main.py)
- [alembic.ini](alembic.ini)
- [app/tests/conftest.py](app/tests/conftest.py)
+30
View File
@@ -0,0 +1,30 @@
# Changelog
All notable changes to this project will be documented in this file.
## [v0.1.21] - 2026-05-11
Released: https://github.com/furyhawk/listen/releases/tag/v0.1.21
### Added
- Implement IoT logging endpoint and related models/schemas. (commit: 255a6f0)
- Update IoT endpoint and tests for improved functionality and type hints. (commit: 38e71f8)
- Add date range tests for pressure and humidity search endpoints. (commit: ce6734d)
- Add VSCode settings for pytest and create AGENTS.md for AI coding guidance. (commit: d8fef06)
### Changed
- Update README with new live example URL and add isort to development dependencies. (commit: 7ab3fa8)
- Bump dependency groups and various dependency updates across the repo. (multiple commits)
- Improve code formatting and organization in test files and migration scripts. (commit: 63e946f)
### Misc
- Merge dependency and workflow updates from dependabot and maintenance PRs. (multiple commits)
- Chore: add development and testing dependencies to `requirements-dev.txt`. (commit: b530a35)
## [Unreleased]
- (future changes go here)
+21 -6
View File
@@ -1,19 +1,34 @@
FROM python:3.13.3-slim-bookworm as base
FROM python:3.14-slim-bookworm as base
ENV PYTHONUNBUFFERED 1
WORKDIR /build
# Create requirements.txt file
FROM base as poetry
RUN pip install poetry==1.8.2
COPY poetry.lock pyproject.toml ./
RUN poetry export -o /requirements.txt --without-hashes
FROM base as deps
RUN pip install uv==0.4.26
COPY pyproject.toml ./
RUN uv pip compile pyproject.toml -o /requirements.txt
FROM base as common
COPY --from=poetry /requirements.txt .
COPY --from=deps /requirements.txt .
# Create venv, add it to path and install requirements
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
build-essential \
libpq-dev \
libffi-dev \
python3-dev \
&& rm -rf /var/lib/apt/lists/*
# Ensure pip/setuptools/wheel and Rust toolchain helpers are up-to-date so
# binary wheels are preferred and building from source can succeed when needed.
# Upgrade pip and wheel tooling so prebuilt wheels are preferred
RUN pip install --upgrade pip setuptools wheel
# Install Python dependencies (will build wheels if prebuilt wheels are unavailable)
RUN pip install -r requirements.txt
# Install uvicorn server
+9 -8
View File
@@ -1,10 +1,11 @@
[![Live example](https://img.shields.io/badge/live%20example-https%3A%2F%2Fminimal--fastapi--postgres--template.rafsaf.pl-blueviolet)](https://minimal-fastapi-postgres-template.rafsaf.pl/)
[![Live example](https://img.shields.io/badge/Live%20example-https%3A%2F%2Fapi.furyhawk.lol-blue)](https://api.furyhawk.lol/)
[![License](https://img.shields.io/github/license/rafsaf/minimal-fastapi-postgres-template)](https://github.com/rafsaf/minimal-fastapi-postgres-template/blob/main/LICENSE)
[![Python 3.13](https://img.shields.io/badge/python-3.13-blue)](https://docs.python.org/3/whatsnew/3.13.html)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![Tests](https://github.com/rafsaf/minimal-fastapi-postgres-template/actions/workflows/tests.yml/badge.svg)](https://github.com/rafsaf/minimal-fastapi-postgres-template/actions/workflows/tests.yml)
[![type-check](https://github.com/furyhawk/listen/actions/workflows/type_check.yml/badge.svg)](https://github.com/furyhawk/listen/actions/workflows/type_check.yml)
_Check out online example: https://minimal-fastapi-postgres-template.rafsaf.pl, it's 100% code used in template (docker image) with added my domain and https only._
_Check out online example: https://api.furyhawk.lol/, it's 100% code used in template (docker image) with added my domain and https only._
# Minimal async FastAPI + PostgreSQL template
@@ -12,7 +13,7 @@ _Check out online example: https://minimal-fastapi-postgres-template.rafsaf.pl,
- [Features](#features)
- [Quickstart](#quickstart)
- [1. Create repository from a template](#1-create-repository-from-a-template)
- [2. Install dependecies with Poetry](#2-install-dependecies-with-poetry)
- [2. Install dependecies with uv](#2-install-dependecies-with-uv)
- [3. Setup database and migrations](#3-setup-database-and-migrations)
- [4. Now you can run app](#4-now-you-can-run-app)
- [5. Activate pre-commit](#5-activate-pre-commit)
@@ -38,7 +39,7 @@ _Check out online example: https://minimal-fastapi-postgres-template.rafsaf.pl,
- [x] Full [Alembic](https://alembic.sqlalchemy.org/en/latest/) migrations setup
- [x] Refresh token endpoint (not only access like in official template)
- [x] Ready to go Dockerfile with [uvicorn](https://www.uvicorn.org/) webserver as an example
- [x] [Poetry](https://python-poetry.org/docs/), `mypy`, `pre-commit` hooks with [ruff](https://github.com/astral-sh/ruff)
- [x] [uv](https://docs.astral.sh/uv/), `mypy`, `pre-commit` hooks with [ruff](https://github.com/astral-sh/ruff)
- [x] Perfect pytest asynchronous test setup with +40 tests and full coverage
<br>
@@ -55,16 +56,16 @@ _Check out online example: https://minimal-fastapi-postgres-template.rafsaf.pl,
See [docs](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template).
### 2. Install dependecies with [Poetry](https://python-poetry.org/docs/)
### 2. Install dependecies with [uv](https://docs.astral.sh/uv/)
```bash
cd your_project_name
### Poetry install (python3.13)
poetry install
### uv install (python3.13)
uv sync
```
Note, be sure to use `python3.13` with this template with either poetry or standard venv & pip, if you need to stick to some earlier python version, you should adapt it yourself (remove new versions specific syntax for example `str | int` for python < 3.10)
Note, be sure to use `python3.13` with this template with either uv or a standard venv and pip setup. If you need to stick to an earlier Python version, adapt the project accordingly.
### 3. Setup database and migrations
@@ -0,0 +1,71 @@
"""create_iot_tables
Revision ID: f1e2d3c4b5a6
Revises: 670c74d20f60
Create Date: 2026-05-11 00:01:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f1e2d3c4b5a6"
down_revision = "670c74d20f60"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"iot_device",
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("node", sa.String(length=128), nullable=True),
sa.Column(
"create_time",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"update_time",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("device_id"),
)
op.create_table(
"iot_reading",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("sensor_type", sa.String(length=64), nullable=False),
sa.Column("value", sa.String(length=128), nullable=False),
sa.Column(
"create_time",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"update_time",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["device_id"], ["iot_device.device_id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("iot_reading")
op.drop_table("iot_device")
# ### end Alembic commands ###
+10 -1
View File
@@ -1,7 +1,15 @@
from fastapi import APIRouter
from app.api import api_messages
from app.api.endpoints import auth, humidity, pets, pressure, temperature, users
from app.api.endpoints import (
auth,
humidity,
iot,
pets,
pressure,
temperature,
users,
)
auth_router = APIRouter()
auth_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -38,3 +46,4 @@ api_router.include_router(pressure.router, prefix="/pressure", tags=["pressure"]
api_router.include_router(
temperature.router, prefix="/temperature", tags=["temperature"]
)
api_router.include_router(iot.router, prefix="/iot", tags=["iot"])
+23 -7
View File
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends, status
from datetime import datetime
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,15 +29,29 @@ async def create_new_humidity(
@router.get(
"/all",
"/search",
response_model=list[HumidityResponse],
status_code=status.HTTP_200_OK,
description="Get humidity of the city.",
description="Search humidity readings with optional date range and limit.",
)
async def get_humidity(
async def search_humidity(
session: AsyncSession = Depends(deps.get_session),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
start_date: datetime | None = Query(
None, description="Filter readings from this date (ISO 8601)"
),
end_date: datetime | None = Query(
None, description="Filter readings until this date (ISO 8601)"
),
) -> list[Humidity]:
humidity_list = await session.scalars(
select(Humidity).order_by(Humidity.create_time.desc())
)
query = select(Humidity)
if start_date:
query = query.where(Humidity.create_time >= start_date)
if end_date:
query = query.where(Humidity.create_time <= end_date)
query = query.order_by(Humidity.create_time.desc()).offset(offset).limit(limit)
humidity_list = await session.scalars(query)
return list(humidity_list.all())
+40
View File
@@ -0,0 +1,40 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api import deps
from app.models import IoTDevice, IoTReading
from app.schemas.requests import IoTLogRequest
from app.schemas.responses import IoTLogResponse
router = APIRouter()
@router.post(
"/log",
response_model=IoTLogResponse,
status_code=status.HTTP_201_CREATED,
description="Log batch IoT device sensor readings received from MQTT",
)
async def log_iot_readings(
data: IoTLogRequest, session: AsyncSession = Depends(deps.get_session)
) -> dict[str, int]:
total = 0
for device in data.devices:
existing = await session.scalar(
select(IoTDevice).where(IoTDevice.device_id == device.device_id)
)
if existing is None:
new_dev = IoTDevice(device_id=device.device_id)
session.add(new_dev)
for r in device.readings:
new_reading = IoTReading(
device_id=device.device_id, sensor_type=r.sensor_type, value=r.value
)
session.add(new_reading)
total += 1
await session.commit()
return {"logged_count": total}
+23 -7
View File
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends, status
from datetime import datetime
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import ScalarResult, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,15 +29,29 @@ async def create_new_pressure(
@router.get(
"/all",
"/search",
response_model=list[PressureResponse],
status_code=status.HTTP_200_OK,
description="Get pressure of the city.",
description="Search pressure readings with optional date range and limit.",
)
async def get_pressure(
async def search_pressure(
session: AsyncSession = Depends(deps.get_session),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
start_date: datetime | None = Query(
None, description="Filter readings from this date (ISO 8601)"
),
end_date: datetime | None = Query(
None, description="Filter readings until this date (ISO 8601)"
),
) -> list[Pressure]:
pressure_list: ScalarResult[Pressure] = await session.scalars(
select(Pressure).order_by(Pressure.create_time.desc())
)
query = select(Pressure)
if start_date:
query = query.where(Pressure.create_time >= start_date)
if end_date:
query = query.where(Pressure.create_time <= end_date)
query = query.order_by(Pressure.create_time.desc()).offset(offset).limit(limit)
pressure_list: ScalarResult[Pressure] = await session.scalars(query)
return list(pressure_list.all())
+23 -7
View File
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends, status
from datetime import datetime
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,15 +29,29 @@ async def create_new_temperature(
@router.get(
"/all",
"/search",
response_model=list[TemperatureResponse],
status_code=status.HTTP_200_OK,
description="Get temperature of the city.",
description="Search temperature readings with optional date range, limit, and offset pagination.",
)
async def get_temperature(
async def search_temperature(
session: AsyncSession = Depends(deps.get_session),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
start_date: datetime | None = Query(
None, description="Filter readings from this date (ISO 8601)"
),
end_date: datetime | None = Query(
None, description="Filter readings until this date (ISO 8601)"
),
) -> list[Temperature]:
temperature_list = await session.scalars(
select(Temperature).order_by(Temperature.create_time.desc())
)
query = select(Temperature)
if start_date:
query = query.where(Temperature.create_time >= start_date)
if end_date:
query = query.where(Temperature.create_time <= end_date)
query = query.order_by(Temperature.create_time.desc()).offset(offset).limit(limit)
temperature_list = await session.scalars(query)
return list(temperature_list.all())
+21
View File
@@ -86,3 +86,24 @@ class Pressure(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
pressure: Mapped[str] = mapped_column(String(10), nullable=False)
class IoTDevice(Base):
__tablename__ = "iot_device"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
node: Mapped[str] = mapped_column(String(128), nullable=True)
readings: Mapped[list["IoTReading"]] = relationship(back_populates="device")
class IoTReading(Base):
__tablename__ = "iot_reading"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
device_id: Mapped[str] = mapped_column(
ForeignKey("iot_device.device_id", ondelete="CASCADE"), nullable=False
)
sensor_type: Mapped[str] = mapped_column(String(64), nullable=False)
value: Mapped[str] = mapped_column(String(128), nullable=False)
# reading_time is optional — use create_time from Base for recorded time
device: Mapped["IoTDevice"] = relationship(back_populates="readings")
+16
View File
@@ -58,3 +58,19 @@ class MqttCreateRequest(BaseRequest):
# "id": "000617968C1EEAD9DB5B00000CFD0E24",
# "flags": {"retain": false, "dup": false},
# }
class SensorReading(BaseRequest):
sensor_type: str
value: str
timestamp: int | None = None
class DeviceData(BaseRequest):
device_id: str
readings: list[SensorReading]
class IoTLogRequest(BaseRequest):
devices: list[DeviceData]
received_at: int | None = None
+12
View File
@@ -42,3 +42,15 @@ class PressureResponse(BaseResponse):
id: int
pressure: str
update_time: datetime
class IoTReadingResponse(BaseResponse):
id: int
device_id: str
sensor_type: str
value: str
update_time: datetime
class IoTLogResponse(BaseResponse):
logged_count: int
+1 -1
View File
@@ -138,6 +138,6 @@ async def fixture_default_user(
yield default_user
@pytest_asyncio.fixture(name="default_user_headers", scope="function")
@pytest.fixture(name="default_user_headers", scope="function")
def fixture_default_user_headers(default_user: User) -> dict[str, str]:
return {"Authorization": f"Bearer {default_user_access_token}"}
+33
View File
@@ -0,0 +1,33 @@
import types
from typing import Any, cast
import pytest
from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api import api_messages, deps
class FakeSession:
async def scalar(self, _query: Any) -> Any:
return None
@pytest.mark.asyncio
async def test_get_current_user_raises_when_user_removed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# make verify_jwt_token return an object with .sub
monkeypatch.setattr(
deps,
"verify_jwt_token",
lambda token: types.SimpleNamespace(sub="non-existent"),
)
with pytest.raises(HTTPException) as excinfo:
await deps.get_current_user(
token="ignored", session=cast(AsyncSession, FakeSession())
)
assert excinfo.value.status_code == status.HTTP_401_UNAUTHORIZED
assert excinfo.value.detail == api_messages.JWT_ERROR_USER_REMOVED
+56
View File
@@ -0,0 +1,56 @@
import pytest
from fastapi import status
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.main import app
from app.models import IoTReading
@pytest.mark.asyncio(loop_scope="session")
async def test_log_iot_readings_single_device(
client: AsyncClient, session: AsyncSession
) -> None:
payload = {
"devices": [
{
"device_id": "dev-1",
"readings": [{"sensor_type": "temperature", "value": "25.5"}],
}
]
}
response = await client.post(app.url_path_for("log_iot_readings"), json=payload)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["logged_count"] == 1
@pytest.mark.asyncio(loop_scope="session")
async def test_log_iot_readings_persists_multiple(
client: AsyncClient, session: AsyncSession
) -> None:
payload = {
"devices": [
{
"device_id": "dev-2",
"readings": [
{"sensor_type": "temperature", "value": "21.0"},
{"sensor_type": "humidity", "value": "60%"},
],
},
{
"device_id": "dev-3",
"readings": [{"sensor_type": "pressure", "value": "1001"}],
},
]
}
response = await client.post(app.url_path_for("log_iot_readings"), json=payload)
assert response.status_code == status.HTTP_201_CREATED
expected_count = sum(len(d["readings"]) for d in payload["devices"])
assert response.json()["logged_count"] == expected_count
rows = await session.scalars(select(IoTReading))
all_readings = list(rows.all())
assert len(all_readings) >= expected_count
+110 -42
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timedelta
import pytest
from fastapi import status
from httpx import AsyncClient
@@ -6,6 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.main import app
from app.models import Humidity, Pressure, Temperature
MIN_EXPECTED_RESULTS = 2
@pytest.mark.asyncio(loop_scope="session")
async def test_create_new_temperature(client: AsyncClient) -> None:
@@ -35,7 +39,7 @@ async def test_create_new_temperature(client: AsyncClient) -> None:
@pytest.mark.asyncio(loop_scope="session")
async def test_get_temperature(
async def test_search_temperature(
client: AsyncClient,
session: AsyncSession,
) -> None:
@@ -47,22 +51,40 @@ async def test_get_temperature(
await session.commit()
response = await client.get(
app.url_path_for("get_temperature"),
app.url_path_for("search_temperature"),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) >= MIN_EXPECTED_RESULTS
assert response.json() == [
{
"update_time": temperature1.update_time.isoformat().replace("+00:00", "Z"),
"temperature": temperature1.temperature,
"id": temperature1.id,
},
{
"update_time": temperature2.update_time.isoformat().replace("+00:00", "Z"),
"temperature": temperature2.temperature,
"id": temperature2.id,
},
]
@pytest.mark.asyncio(loop_scope="session")
async def test_search_temperature_with_limit(
client: AsyncClient,
session: AsyncSession,
) -> None:
response = await client.get(
app.url_path_for("search_temperature"),
params={"limit": 1},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) <= 1
@pytest.mark.asyncio(loop_scope="session")
async def test_search_temperature_with_date_range(
client: AsyncClient,
session: AsyncSession,
) -> None:
now = datetime.utcnow()
start_date = (now - timedelta(days=1)).isoformat() + "Z"
end_date = (now + timedelta(days=1)).isoformat() + "Z"
response = await client.get(
app.url_path_for("search_temperature"),
params={"start_date": start_date, "end_date": end_date},
)
assert response.status_code == status.HTTP_200_OK
assert isinstance(response.json(), list)
@pytest.mark.asyncio(loop_scope="session")
@@ -93,7 +115,7 @@ async def test_create_new_pressure(client: AsyncClient) -> None:
@pytest.mark.asyncio(loop_scope="session")
async def test_get_pressure(
async def test_search_pressure(
client: AsyncClient,
session: AsyncSession,
) -> None:
@@ -105,22 +127,45 @@ async def test_get_pressure(
await session.commit()
response = await client.get(
app.url_path_for("get_pressure"),
app.url_path_for("search_pressure"),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) >= MIN_EXPECTED_RESULTS
assert response.json() == [
{
"update_time": pressure1.update_time.isoformat().replace("+00:00", "Z"),
"pressure": pressure1.pressure,
"id": pressure1.id,
},
{
"update_time": pressure2.update_time.isoformat().replace("+00:00", "Z"),
"pressure": pressure2.pressure,
"id": pressure2.id,
},
]
@pytest.mark.asyncio(loop_scope="session")
async def test_search_pressure_with_limit(
client: AsyncClient,
session: AsyncSession,
) -> None:
response = await client.get(
app.url_path_for("search_pressure"),
params={"limit": 1},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) <= 1
@pytest.mark.asyncio(loop_scope="session")
async def test_search_pressure_with_date_range(
client: AsyncClient,
session: AsyncSession,
) -> None:
now = datetime.utcnow()
start_date = (now - timedelta(days=1)).isoformat() + "Z"
end_date = (now + timedelta(days=1)).isoformat() + "Z"
# ensure at least one record exists in this range
pressure = Pressure(pressure="1002.0")
session.add(pressure)
await session.commit()
response = await client.get(
app.url_path_for("search_pressure"),
params={"start_date": start_date, "end_date": end_date},
)
assert response.status_code == status.HTTP_200_OK
assert isinstance(response.json(), list)
@pytest.mark.asyncio(loop_scope="session")
@@ -151,7 +196,7 @@ async def test_create_new_humidity(client: AsyncClient) -> None:
@pytest.mark.asyncio(loop_scope="session")
async def test_get_humidity(
async def test_search_humidity(
client: AsyncClient,
session: AsyncSession,
) -> None:
@@ -163,19 +208,42 @@ async def test_get_humidity(
await session.commit()
response = await client.get(
app.url_path_for("get_humidity"),
app.url_path_for("search_humidity"),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) >= MIN_EXPECTED_RESULTS
assert response.json() == [
{
"update_time": humidity1.update_time.isoformat().replace("+00:00", "Z"),
"humidity": humidity1.humidity,
"id": humidity1.id,
},
{
"update_time": humidity2.update_time.isoformat().replace("+00:00", "Z"),
"humidity": humidity2.humidity,
"id": humidity2.id,
},
]
@pytest.mark.asyncio(loop_scope="session")
async def test_search_humidity_with_limit(
client: AsyncClient,
session: AsyncSession,
) -> None:
response = await client.get(
app.url_path_for("search_humidity"),
params={"limit": 1},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) <= 1
@pytest.mark.asyncio(loop_scope="session")
async def test_search_humidity_with_date_range(
client: AsyncClient,
session: AsyncSession,
) -> None:
now = datetime.utcnow()
start_date = (now - timedelta(days=1)).isoformat() + "Z"
end_date = (now + timedelta(days=1)).isoformat() + "Z"
# ensure at least one record exists in this range
humidity = Humidity(humidity="55.0")
session.add(humidity)
await session.commit()
response = await client.get(
app.url_path_for("search_humidity"),
params={"start_date": start_date, "end_date": end_date},
)
assert response.status_code == status.HTTP_200_OK
assert isinstance(response.json(), list)
Generated
-1709
View File
File diff suppressed because it is too large Load Diff
+33 -31
View File
@@ -1,39 +1,41 @@
[tool.poetry]
authors = ["admin <admin@example.com>"]
[project]
authors = [{email = "admin@example.com", name = "admin"}]
description = "FastAPI project generated using minimal-fastapi-postgres-template."
name = "app"
version = "0.1.0-alpha"
version = "0.1.23"
requires-python = ">=3.13"
dependencies = [
"alembic>=1.18.3,<2",
"asyncpg>=0.31.0,<1",
"bcrypt>=5.0.0,<6",
"fastapi>=0.136.1,<1",
"pydantic[email]>=2.13.4,<3",
"pydantic-settings>=2.14.1,<3",
"pyjwt>=2.11.0,<3",
"python-multipart>=0.0.28,<1",
"sqlalchemy[asyncio]>=2.0.46,<3",
]
[tool.poetry.dependencies]
python = "^3.13"
alembic = "^1.14.0"
asyncpg = "^0.30.0"
bcrypt = "^4.3.0"
fastapi = "^0.115.10"
pydantic = { extras = ["dotenv", "email"], version = "^2.10.6" }
pydantic-settings = "^2.8.1"
pyjwt = "^2.10.1"
python-multipart = "^0.0.20"
sqlalchemy = {extras = ["asyncio"], version = "^2.0.38"}
[tool.poetry.group.dev.dependencies]
coverage = "^7.6.12"
freezegun = "^1.5.1"
greenlet = "^3.1.1"
httpx = "^0.28.1"
mypy = "^1.15.0"
pre-commit = "^4.0.1"
pytest = "^8.3.4"
pytest-asyncio = "^0.26.0"
pytest-cov = "^6.0.0"
pytest-xdist = "^3.6.1"
ruff = "^0.9.9"
uvicorn = { extras = ["standard"], version = "^0.34.0" }
[project.optional-dependencies]
dev = [
"coverage>=7.14.0,<8",
"freezegun>=1.5.5,<2",
"greenlet>=3.5.0,<4",
"httpx>=0.28.1,<1",
"mypy>=2.0.0,<3",
"pre-commit>=4.6.0,<5",
"pytest>=9.0.3,<10",
"pytest-asyncio>=1.0.0,<2",
"pytest-cov>=7.1.0,<8",
"pytest-xdist>=3.8.0,<4",
"ruff>=0.15.12,<1",
"isort>=8.0.1,<9",
"uvicorn[standard]>=0.46.0,<1",
]
[build-system]
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core>=1.0.0"]
build-backend = "hatchling.build"
requires = ["hatchling"]
[tool.pytest.ini_options]
addopts = "-vv -n auto --cov --cov-report xml --cov-report term-missing --cov-fail-under=92"
+27
View File
@@ -0,0 +1,27 @@
Release v0.1.21
Changes since v0.1.20:
- d513c69 your message
- c01b419 Merge pull request #86 from furyhawk/dependabot/pip/all-dependencies-242ef55d95
- 7ab3fa8 feat: Update README with new live example URL and add isort to development dependencies
- debec44 chore(deps): Bump the all-dependencies group across 1 directory with 16 updates
- 63e946f refactor: Improve code formatting and organization in test files and migration scripts
- 38e71f8 feat: Update IoT endpoint and tests for improved functionality and type hints
- 255a6f0 feat: Implement IoT logging endpoint and related models/schemas
- ce6734d feat: Add date range tests for pressure and humidity search endpoints
- b530a35 chore: Add development and testing dependencies to requirements-dev.txt
- d8fef06 Add VSCode settings for pytest and create AGENTS.md for AI coding guidance
- 773dedd Merge pull request #82 from furyhawk/dependabot/github_actions/docker/login-action-4
- 9f8ac44 Merge pull request #84 from furyhawk/dependabot/github_actions/docker/setup-qemu-action-4
- e638924 Merge pull request #83 from furyhawk/dependabot/docker/python-3.15.0a8-slim-bookworm
- 2c8f0a4 chore(deps): Bump docker/setup-qemu-action from 3 to 4
- 325360e chore(deps): Bump docker/login-action from 3 to 4
- e064423 chore(deps): Bump python
- 13a7999 Merge pull request #80 from furyhawk/dependabot/pip/all-dependencies-f44031ab6e
- 0e578a3 chore: merge main into PR branch, resolving conflicts
- 56353bb chore(deps): Bump the all-dependencies group across 1 directory with 50 updates
Notes:
- Tag `v0.1.21` created at HEAD and pushed to origin.
- Please verify CI artifacts if needed.
+7
View File
@@ -0,0 +1,7 @@
Release v0.1.22
- Fix Dockerfile: switch to `python:3.14-slim-bookworm`, remove Rust toolchain install, and rely on prebuilt wheels where possible.
- Install necessary build deps for wheel compilation (`build-essential`, `libpq-dev`, `libffi-dev`, `python3-dev`).
- Prepare image build improvements to avoid heavy Rust installs during CI/local builds.
See CHANGELOG.md for past releases.
+6
View File
@@ -0,0 +1,6 @@
Release v0.1.23
- Bump version to 0.1.23.
- Small packaging and Dockerfile fixes (Dockerfile parse error fixed).
See CHANGELOG.md for historical changes.
+23
View File
@@ -0,0 +1,23 @@
# Development / test dependencies for local setup
# Install with: `python -m pip install -r requirements-dev.txt`
# Testing
pytest
pytest-asyncio
pytest-cov
pytest-xdist
# Tools
ruff
mypy
pre-commit
isort
# HTTP client for tests
httpx
# Coverage helper
coverage
# Uvicorn for local run
uvicorn[standard]
+64
View File
@@ -0,0 +1,64 @@
# This file was autogenerated by uv via the following command:
# uv pip compile pyproject.toml -o requirements.txt
alembic==1.18.4
# via app (pyproject.toml)
annotated-doc==0.0.4
# via fastapi
annotated-types==0.7.0
# via pydantic
anyio==4.13.0
# via starlette
asyncpg==0.31.0
# via app (pyproject.toml)
bcrypt==4.3.0
# via app (pyproject.toml)
dnspython==2.8.0
# via email-validator
email-validator==2.3.0
# via pydantic
fastapi==0.136.1
# via app (pyproject.toml)
greenlet==3.5.0
# via sqlalchemy
idna==3.14
# via
# anyio
# email-validator
mako==1.3.12
# via alembic
markupsafe==3.0.3
# via mako
pydantic==2.13.4
# via
# app (pyproject.toml)
# fastapi
# pydantic-settings
pydantic-core==2.46.4
# via pydantic
pydantic-settings==2.14.1
# via app (pyproject.toml)
pyjwt==2.12.1
# via app (pyproject.toml)
python-dotenv==1.2.2
# via pydantic-settings
python-multipart==0.0.28
# via app (pyproject.toml)
sqlalchemy==2.0.49
# via
# app (pyproject.toml)
# alembic
starlette==1.0.0
# via fastapi
typing-extensions==4.15.0
# via
# alembic
# fastapi
# pydantic
# pydantic-core
# sqlalchemy
# typing-inspection
typing-inspection==0.4.2
# via
# fastapi
# pydantic
# pydantic-settings
Generated
+1293
View File
File diff suppressed because it is too large Load Diff