Compare commits

...

83 Commits
ph4 ... prod

Author SHA1 Message Date
mo
6e812431c8 Merge pull request 'ph5' () from ph5 into prod
Reviewed-on: 
2025-06-10 08:17:31 +02:00
mohamad
448a0705d2 feat: Implement comprehensive roadmap for feature updates and enhancements
This commit introduces a detailed roadmap for implementing various features, focusing on backend and frontend improvements. Key additions include:

- New database schema designs for financial audit logging, archiving lists, and categorizing items.
- Backend logic for financial audit logging, archiving functionality, and chore subtasks.
- Frontend UI updates for archiving lists, managing categories, and enhancing the chore interface.
- Introduction of a guest user flow and integration of Redis for caching to improve performance.

These changes aim to enhance the application's functionality, user experience, and maintainability.
2025-06-10 08:16:55 +02:00
mohamad
7ffeae1476 feat: Add new components for cost summary, expenses, and item management
This commit introduces several new components to enhance the list detail functionality:

- **CostSummaryDialog.vue**: A modal for displaying cost summaries, including total costs, user balances, and a detailed breakdown of expenses.
- **ExpenseSection.vue**: A section for managing and displaying expenses, featuring loading states, error handling, and collapsible item details.
- **ItemsList.vue**: A component for rendering and managing a list of items with drag-and-drop functionality, including a new item input field.
- **ListItem.vue**: A detailed item component that supports editing, deleting, and displaying item statuses.
- **OcrDialog.vue**: A modal for handling OCR file uploads and displaying extracted items.
- **SettleShareModal.vue**: A modal for settling shares among users, allowing input of settlement amounts.
- **Error handling utility**: A new utility function for extracting user-friendly error messages from API responses.

These additions aim to improve user interaction and streamline the management of costs and expenses within the application.
2025-06-09 22:55:37 +02:00
mo
5c57ac8080 Merge pull request 'refactor: Update ListDetailPage and listDetailStore for improved type safety and state management' () from ph5 into prod
Reviewed-on: 
2025-06-09 21:14:21 +02:00
mohamad
7ffd4b9a91 refactor: Update ListDetailPage and listDetailStore for improved type safety and state management
This commit refactors the ListDetailPage and listDetailStore to enhance type safety and streamline state management. Key changes include:

- Removed the import of ExpenseCard from ListDetailPage.vue.
- Introduced specific types for category options and improved type annotations in computed properties.
- Transitioned listDetailStore to use the Composition API, replacing the previous state management structure with refs and computed properties for better reactivity.
- Updated the fetchListWithExpenses action to handle errors more gracefully and ensure the current list is correctly populated with expenses.

These changes aim to improve code maintainability and enhance the overall functionality of the application.
2025-06-09 21:13:31 +02:00
mo
453ce9e45f Merge pull request 'feat: Introduce FastAPI and Vue.js guidelines, enhance API structure, and add caching support' () from ph5 into prod
Reviewed-on: 
2025-06-09 21:03:27 +02:00
mohamad
f49e15c05c feat: Introduce FastAPI and Vue.js guidelines, enhance API structure, and add caching support
This commit adds new guidelines for FastAPI and Vue.js development, emphasizing best practices for component structure, API performance, and data handling. It also introduces caching mechanisms using Redis for improved performance and updates the API structure to streamline authentication and user management. Additionally, new endpoints for categories and time entries are implemented, enhancing the overall functionality of the application.
2025-06-09 21:02:51 +02:00
mo
b9434e6b56 Merge pull request 'ph5' () from ph5 into prod
Reviewed-on: 
2025-06-09 15:27:00 +02:00
mohamad
bbe3b3a493 fix: Update API base URL to production environment 2025-06-09 15:26:19 +02:00
mohamad
8a0457aeec refactor: Clean up code and improve API structure 2025-06-09 15:14:34 +02:00
mo
a7a01b90cf Merge pull request 'ph5' () from ph5 into prod
Reviewed-on: 
2025-06-09 14:04:57 +02:00
mohamad
10845d2e5f feat: Enhance OCR processing and UI components
This commit introduces significant updates to the OCR processing logic and UI components:

- Updated the OCR extraction prompt in `config.py` to provide detailed instructions for handling shopping list images, improving accuracy in item identification and extraction.
- Upgraded the `GEMINI_MODEL_NAME` to a newer version for enhanced OCR capabilities.
- Added a new `CreateGroupModal.vue` component for creating groups, improving user interaction.
- Updated `MainLayout.vue` to integrate the new group creation modal and enhance the user menu and language selector functionality.
- Improved styling and structure in various components, including `ChoresPage.vue` and `GroupDetailPage.vue`, for better user experience and visual consistency.

These changes aim to enhance the overall functionality and usability of the application.
2025-06-09 14:04:30 +02:00
mohamad
dccd7bb300 feat: Implement JWT refresh token endpoint and update OAuth routing 2025-06-09 13:06:01 +02:00
mo
2d70850840 Merge pull request 'ph5' () from ph5 into prod
Reviewed-on: 
2025-06-08 12:29:29 +02:00
mohamad
3ec2ff1f89 feat: Add group members endpoint and enhance UI translations
This commit introduces a new API endpoint to retrieve group members, ensuring that only authorized users can access member information. Additionally, it updates the UI with improved translations for chore management, group lists, and activity logs in both English and Dutch. Styling adjustments in the ListDetailPage enhance user interaction, while minor changes in the SCSS file improve the overall visual presentation.
2025-06-08 12:29:09 +02:00
mohamad
8afeda1df7 Enhance ChoresPage and GroupDetailPage with improved styling and UI updates
This commit introduces the following changes:

- Updated styling for overdue and due-today chore statuses in ChoresPage, replacing border styles with box shadows for better visibility.
- Adjusted opacity for completed chores to enhance UI clarity.
- Minor formatting fixes in GroupDetailPage for improved button and text alignment.

These updates aim to enhance the user experience by providing clearer visual cues and a more polished interface.
2025-06-08 11:03:32 +02:00
mo
471c7b069d Merge pull request 'Fix API base URL in api-config.ts to correct domain' () from ph5 into prod
Reviewed-on: 
2025-06-08 10:23:12 +02:00
mohamad
26f589751d Fix API base URL in api-config.ts to correct domain 2025-06-08 10:22:52 +02:00
mo
51474695ef Merge pull request 'Update API base URL to production environment in api-config.ts' () from ph5 into prod
Reviewed-on: 
2025-06-08 02:08:58 +02:00
mohamad
81f551a21d Update API base URL to production environment in api-config.ts 2025-06-08 02:08:36 +02:00
mo
d13a231113 Merge pull request 'ph5' () from ph5 into prod
Reviewed-on: 
2025-06-08 02:04:07 +02:00
mohamad
88c9516308 feat: Enhance GroupDetailPage with chore assignments and history
This update introduces significant improvements to the GroupDetailPage, including:

- Added detailed modals for chore assignments and history.
- Implemented loading states for assignments and chore history.
- Enhanced chore display with status indicators for overdue and due-today.
- Improved UI with new styles for chore items and assignment details.

These changes enhance user experience by providing more context and information about group chores and their assignments.
2025-06-08 02:03:38 +02:00
mohamad
402489c928 feat: Enhance ChoresPage with detail and history modals
This update introduces new functionality to the ChoresPage, including:

- Added modals for viewing chore details and history.
- Implemented loading states for assignments and history.
- Enhanced chore display with assignment and completion details.
- Introduced new types for chore assignments and history.
- Improved UI with badges for overdue and due-today statuses.

These changes improve user experience by providing more context and information about chores and their assignments.
2025-06-08 01:32:53 +02:00
mohamad
f20f3c960d feat: Add language selector and Dutch translations 2025-06-08 01:32:40 +02:00
mohamad
fb951acb72 feat: Add chore history and scheduling functionality
This commit introduces new models and endpoints for managing chore history and scheduling within the application. Key changes include:

- Added `ChoreHistory` and `ChoreAssignmentHistory` models to track changes and events related to chores and assignments.
- Implemented CRUD operations for chore history in the `history.py` module.
- Created endpoints to retrieve chore and assignment history in the `chores.py` and `groups.py` files.
- Introduced a scheduling feature for group chores, allowing for round-robin assignment generation.
- Updated existing chore and assignment CRUD operations to log history entries for create, update, and delete actions.

This enhancement improves the tracking of chore-related events and facilitates better management of group chore assignments.
2025-06-08 01:17:53 +02:00
mo
3d2bc3846a Merge pull request 'Update API base URL to production environment in api-config.ts' () from ph4 into prod
Reviewed-on: 
2025-06-07 22:14:52 +02:00
mo
ef2caaee56 Merge pull request 'Update logging level to INFO, refine chore update logic, and enhance invite acceptance flow' () from ph4 into prod
Reviewed-on: 
2025-06-07 22:09:00 +02:00
mo
6004911912 Merge pull request 'ph4' () from ph4 into prod
Reviewed-on: 
2025-06-07 18:56:59 +02:00
mo
ef41ebb954 Merge pull request 'ph4' () from ph4 into prod
Reviewed-on: 
2025-06-07 18:08:41 +02:00
mo
24a5024e88 Merge pull request 'ph4' () from ph4 into prod
Reviewed-on: 
2025-06-05 01:05:04 +02:00
mo
acdf1af9b9 Merge pull request 'Update API base URL to production environment' () from ph4 into prod
Reviewed-on: 
2025-06-04 17:56:00 +02:00
mo
f3fdbc0592 Merge pull request 'ph4' () from ph4 into prod
Reviewed-on: 
2025-06-04 17:51:17 +02:00
mo
1f7abcbd85 Merge pull request 'ph4' () from ph4 into prod
Reviewed-on: 
2025-06-02 19:09:21 +02:00
mo
76446cf84e Merge pull request 'Update Dockerfile to use npm install and modify PWA theme and background colors in vite.config.ts' () from ph4 into prod
Reviewed-on: 
2025-06-02 00:29:25 +02:00
mo
df08bdaf9e Merge pull request 'Update vue-i18n dependency to version 9.9.1 in package.json' () from ph4 into prod
Reviewed-on: 
2025-06-02 00:25:41 +02:00
mo
6a61bb8df4 Merge pull request 'ph4' () from ph4 into prod
Reviewed-on: 
2025-06-02 00:20:48 +02:00
mo
e124f05e7b Merge pull request 'Update OAuth redirect URIs and API routing structure' () from ph4 into prod
Reviewed-on: 
2025-06-01 22:43:17 +02:00
mo
f60002d98e Merge pull request 'Refactor API routing and update login URLs' () from ph4 into prod
Reviewed-on: 
2025-06-01 22:38:08 +02:00
mo
708a6280d6 Merge pull request 'Update API base URL in api-config.ts to point to the new production environment' () from ph4 into prod
Reviewed-on: 
2025-06-01 22:16:58 +02:00
mo
20e1c2ac69 Merge pull request 'ph4' () from ph4 into prod
Reviewed-on: 
2025-06-01 22:03:25 +02:00
mo
e777268643 Merge pull request 'Update API base URL in api-config.ts to point to the production environment' () from ph4 into prod
Reviewed-on: 
2025-06-01 21:06:58 +02:00
mo
3be38002e7 Merge pull request 'Refactor: Update styling and functionality in various components' () from ph4 into prod
Reviewed-on: 
2025-06-01 20:41:23 +02:00
mo
d23219fd60 Merge pull request 'Refactor GroupsPage: Replace VButton and VIcon components with standard HTML button and SVG for improved compatibility and maintainability. Added console logs for better debugging during the create list dialog flow.' () from ph4 into prod
Reviewed-on: 
2025-06-01 20:00:16 +02:00
mo
088f371547 Merge pull request 'Enhance group selection flow by ensuring latest groups data is fetched before opening the create list dialog. Additionally, refresh the groups list after a new list is created to reflect updates. This improves data consistency and user experience on th…' () from ph4 into prod
Reviewed-on: 
2025-06-01 19:56:27 +02:00
mo
b5f16a3d0d Merge pull request 'Refactor: Replace button elements with VButton and VIcon components in GroupsPage' () from ph4 into prod
Reviewed-on: 
2025-06-01 19:51:23 +02:00
mo
0a6877852a Merge pull request 'ph4' () from ph4 into prod
Reviewed-on: 
2025-06-01 19:19:20 +02:00
mo
d3d5f88e09 Merge pull request 'refactor: Simplify upgrade function by directly creating enums and adding new tables for chores and chore assignments in the initial schema' () from ph4 into prod
Reviewed-on: 
2025-06-01 18:20:43 +02:00
mo
1ccd4456f6 Merge pull request 'refactor: Encapsulate enum creation logic within a dedicated function in the upgrade process for improved readability and maintainability' () from ph4 into prod
Reviewed-on: 
2025-06-01 18:15:41 +02:00
mo
acdb628777 Merge pull request 'refactor: Modify upgrade function to accept context parameter for enhanced migration flexibility' () from ph4 into prod
Reviewed-on: 
2025-06-01 18:13:11 +02:00
mo
463cfe070c Merge pull request 'refactor: Clarify access to revision strings in migration function by referencing Script object within RevisionStep' () from ph4 into prod
Reviewed-on: 
2025-06-01 18:09:45 +02:00
mo
8a98aee6c1 Merge pull request 'refactor: Update migration function to access revision strings from RevisionStep objects for improved clarity' () from ph4 into prod
Reviewed-on: 
2025-06-01 17:50:18 +02:00
mo
0a42d68853 Merge pull request 'refactor: Introduce migration function to streamline upgrade steps in Alembic migrations' () from ph4 into prod
Reviewed-on: 
2025-06-01 17:45:43 +02:00
mo
26315cd407 Merge pull request 'refactor: Improve Alembic migration functions by integrating configuration and script directory handling for enhanced migration context management' () from ph4 into prod
Reviewed-on: 
2025-06-01 17:42:33 +02:00
mo
8517cbee99 Merge pull request 'refactor: Update migration functions to accept connection parameter for improved flexibility and consistency' () from ph4 into prod
Reviewed-on: 
2025-06-01 17:39:22 +02:00
mo
f882b86f05 Merge pull request 'refactor: Separate async migration logic into dedicated module and streamline migration functions for improved clarity and maintainability' () from ph4 into prod
Reviewed-on: 
2025-06-01 17:33:14 +02:00
mo
5e79be16d3 Merge pull request 'refactor: Enhance Alembic migration functions to support direct execution and improve error handling for database URL configuration' () from ph4 into prod
Reviewed-on: 
2025-06-01 17:30:01 +02:00
mo
d1b8191c8d Merge pull request 'refactor: Update Alembic migration functions to support asynchronous execution and streamline migration handling in application startup' () from ph4 into prod
Reviewed-on: 
2025-06-01 17:20:42 +02:00
mo
8d3bf927b6 Merge pull request 'fix: Add Alembic directory and configuration file to production Dockerfile for migration support' () from ph4 into prod
Reviewed-on: 
2025-06-01 17:16:39 +02:00
mo
e62bceb955 Merge pull request 'fix: Update Alembic configuration to use absolute paths for ini file and script location in migration process' () from ph4 into prod
Reviewed-on: 
2025-06-01 17:13:21 +02:00
mo
99d06baa03 Merge pull request 'fix: Enhance Alembic configuration by setting script location and database URL validation in migration process' () from ph4 into prod
Reviewed-on: 
2025-06-01 17:10:06 +02:00
mo
530867bb16 Merge pull request 'refactor: Simplify Dockerfile by reorganizing Alembic file copying and enhance migration handling in application startup' () from ph4 into prod
Reviewed-on: 
2025-06-01 17:03:25 +02:00
mo
de5f54f970 Merge pull request 'fix: Update Alembic configuration in startup event to set script location and database URL' () from ph4 into prod
Reviewed-on: 
2025-06-01 16:57:22 +02:00
mo
792a7878f0 Merge pull request 'feat: Add Alembic configuration and migration command to application startup' () from ph4 into prod
Reviewed-on: 
2025-06-01 16:54:30 +02:00
mo
c62c0d0157 Merge pull request 'fix ig' () from ph4 into prod
Reviewed-on: 
2025-06-01 16:49:35 +02:00
mo
855dd852c5 Merge pull request 'refactor: Update production Dockerfile to use Node.js for serving built assets and enhance environment variable injection' () from ph4 into prod
Reviewed-on: 
2025-06-01 16:46:19 +02:00
mo
028c991d91 Merge pull request 'refactor: Transition production Dockerfile to use Nginx for serving built assets and streamline environment variable handling' () from ph4 into prod
Reviewed-on: 
2025-06-01 16:39:34 +02:00
mo
1f7f573f64 Merge pull request 'refactor: Update environment variable handling in Dockerfile for production' () from ph4 into prod
Reviewed-on: 
2025-06-01 16:33:08 +02:00
mo
350ccaf5d8 Merge pull request 'refactor: Optimize Dockerfiles and deployment workflow for improved performance and reliability' () from ph4 into prod
Reviewed-on: 
2025-06-01 16:27:10 +02:00
mo
ca73d6ca79 Merge pull request 'refactor: Revise .dockerignore and Dockerfile for enhanced build efficiency and organization' () from ph4 into prod
Reviewed-on: 
2025-06-01 16:15:12 +02:00
mo
d7bd69f68c Merge pull request 'refactor: Improve deployment workflow with retry logic for image pushes and optimized build process' () from ph4 into prod
Reviewed-on: 
2025-06-01 16:04:11 +02:00
mo
fd15ed5a35 Merge pull request 'refactor: Enhance deployment workflow for backend and frontend images' () from ph4 into prod
Reviewed-on: 
2025-06-01 16:01:25 +02:00
mo
0cdc47d0d2 Merge pull request 'refactor: Update .dockerignore for improved clarity and organization' () from ph4 into prod
Reviewed-on: 
2025-06-01 15:58:00 +02:00
mo
c90ee6b73f Merge pull request 'refactor: Standardize user creation in Dockerfile and improve multi-stage build syntax' () from ph4 into prod
Reviewed-on: 
2025-06-01 15:47:57 +02:00
mo
3c30eaeaee Merge pull request 'refactor: Update backend Dockerfile to use Alpine package names' () from ph4 into prod
Reviewed-on: 
2025-06-01 15:46:24 +02:00
mo
1907911779 Merge pull request 'refactor: Switch backend Dockerfile to use Alpine package manager' () from ph4 into prod
Reviewed-on: 
2025-06-01 15:44:29 +02:00
mo
cda51e34ba Merge pull request 'refactor: Update Docker configurations for improved environment variable handling' () from ph4 into prod
Reviewed-on: 
2025-06-01 15:41:57 +02:00
mo
c7f296597e Merge pull request 'refactor: Improve environment variable injection in Dockerfile for production' () from ph4 into prod
Reviewed-on: 
2025-06-01 15:35:15 +02:00
mo
b3fd3acad9 Merge pull request 'fix: Update API base URL for development environment' () from ph4 into prod
Reviewed-on: 
2025-06-01 15:16:16 +02:00
mo
258798846d Merge pull request 'refactor: Update frontend components and Dockerfile for production' () from ph4 into prod
Reviewed-on: 
2025-06-01 14:59:49 +02:00
mo
6f69ad8fcc Merge pull request 'Enhance deployment workflow with context variable debugging and fallback logic' () from ph4 into prod
Reviewed-on: 
2025-06-01 14:51:29 +02:00
mo
7a3e91a324 Merge pull request 'fix: Update Docker image tags' () from ph4 into prod
Reviewed-on: 
2025-06-01 14:47:59 +02:00
mo
e43b4fe50a Merge pull request 'fix: Update Docker login commands in deployment workflow' () from ph4 into prod
Reviewed-on: 
2025-06-01 14:41:35 +02:00
mo
b37cbebf8a Merge pull request 'ph4' () from ph4 into prod
Reviewed-on: 
2025-06-01 14:39:42 +02:00
125 changed files with 8509 additions and 4820 deletions

32
.cursor/rules/fastapi.mdc Normal file
View File

@ -0,0 +1,32 @@
---
description:
globs:
alwaysApply: true
---
# FastAPI-Specific Guidelines:
- Use functional components (plain functions) and Pydantic models for input validation and response schemas.
- Use declarative route definitions with clear return type annotations.
- Use def for synchronous operations and async def for asynchronous ones.
- Minimize @app.on_event("startup") and @app.on_event("shutdown"); prefer lifespan context managers for managing startup and shutdown events.
- Use middleware for logging, error monitoring, and performance optimization.
- Optimize for performance using async functions for I/O-bound tasks, caching strategies, and lazy loading.
- Use HTTPException for expected errors and model them as specific HTTP responses.
- Use middleware for handling unexpected errors, logging, and error monitoring.
- Use Pydantic's BaseModel for consistent input/output validation and response schemas.
Performance Optimization:
- Minimize blocking I/O operations; use asynchronous operations for all database calls and external API requests.
- Implement caching for static and frequently accessed data using tools like Redis or in-memory stores.
- Optimize data serialization and deserialization with Pydantic.
- Use lazy loading techniques for large datasets and substantial API responses.
Key Conventions
1. Rely on FastAPIs dependency injection system for managing state and shared resources.
2. Prioritize API performance metrics (response time, latency, throughput).
3. Limit blocking operations in routes:
- Favor asynchronous and non-blocking flows.
- Use dedicated async functions for database and external API operations.
- Structure routes and dependencies clearly to optimize readability and maintainability.
Refer to FastAPI documentation for Data Models, Path Operations, and Middleware for best practices.

267
.cursor/rules/roadmap.mdc Normal file
View File

@ -0,0 +1,267 @@
---
description:
globs:
alwaysApply: false
---
Of course. Based on a thorough review of your project's structure and code, here is a detailed, LLM-friendly task list to implement the requested features.
This plan is designed to be sequential and modular, focusing on backend database changes first, then backend logic and APIs, and finally the corresponding frontend implementation for each feature.
---
### **High-Level Strategy & Recommendations**
1. **Iterative Implementation:** Tackle one major feature at a time (e.g., complete Audit Logging, then Archiving, etc.). This keeps pull requests manageable and easier to review.
2. **Traceability:** The request for traceability is key. We will use timestamp-based flags (`archived_at`, `deleted_at`) instead of booleans and create dedicated history/log tables for critical actions.
---
### **Phase 1: Database Schema Redesign**
This is the most critical first step. All subsequent tasks depend on these changes. You will need to create a new Alembic migration to apply these.
**File to Modify:** `be/app/models.py`
**Action:** Create a new Alembic migration file (`alembic revision -m "feature_updates_phase1"`) and implement the following changes in `upgrade()`.
**1. Financial Audit Logging**
* Create a new table to log every financial transaction and change. This ensures complete traceability.
```python
# In be/app/models.py
class FinancialAuditLog(Base):
__tablename__ = 'financial_audit_log'
id = Column(Integer, primary_key=True, index=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=True) # User who performed the action. Nullable for system actions.
action_type = Column(String, nullable=False, index=True) # e.g., 'EXPENSE_CREATED', 'SPLIT_PAID', 'SETTLEMENT_DELETED'
entity_type = Column(String, nullable=False) # e.g., 'Expense', 'ExpenseSplit', 'Settlement'
entity_id = Column(Integer, nullable=False)
details = Column(JSONB, nullable=True) # To store 'before' and 'after' states or other relevant data.
user = relationship("User")
```
**2. Archiving Lists and History**
* Modify the `lists` table to support soft deletion/archiving.
```python
# In be/app/models.py, class List(Base):
# REMOVE: is_deleted = Column(Boolean, default=False, nullable=False) # If it exists
archived_at = Column(DateTime(timezone=True), nullable=True, index=True)
```
**3. Chore Subtasks**
* Add a self-referencing foreign key to the `chores` table.
```python
# In be/app/models.py, class Chore(Base):
parent_chore_id = Column(Integer, ForeignKey('chores.id'), nullable=True, index=True)
# Add relationships
parent_chore = relationship("Chore", remote_side=[id], back_populates="child_chores")
child_chores = relationship("Chore", back_populates="parent_chore", cascade="all, delete-orphan")
```
**4. List Categories**
* Create a new `categories` table and link it to the `items` table. This allows items to be categorized.
```python
# In be/app/models.py
class Category(Base):
__tablename__ = 'categories'
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=True) # Nullable for global categories
group_id = Column(Integer, ForeignKey('groups.id'), nullable=True) # Nullable for user-specific or global
# Add constraints to ensure either user_id or group_id is set, or both are null for global categories
__table_args__ = (UniqueConstraint('name', 'user_id', 'group_id', name='uq_category_scope'),)
# In be/app/models.py, class Item(Base):
category_id = Column(Integer, ForeignKey('categories.id'), nullable=True)
category = relationship("Category")
```
**5. Time Tracking for Chores**
* Create a new `time_entries` table to log time spent on chore assignments.
```python
# In be/app/models.py
class TimeEntry(Base):
__tablename__ = 'time_entries'
id = Column(Integer, primary_key=True, index=True)
chore_assignment_id = Column(Integer, ForeignKey('chore_assignments.id', ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False)
end_time = Column(DateTime(timezone=True), nullable=True)
duration_seconds = Column(Integer, nullable=True) # Calculated on end_time set
assignment = relationship("ChoreAssignment")
user = relationship("User")
```
---
### **Phase 2: Backend Implementation**
For each feature, implement the necessary backend logic.
#### **Task 2.1: Implement Financial Audit Logging**
* **Goal:** Automatically log all changes to expenses, splits, and settlements.
* **Tasks:**
1. **CRUD (`be/app/crud/audit.py`):**
* Create a new file `audit.py`.
* Implement `create_financial_audit_log(db: AsyncSession, user_id: int, action_type: str, entity: Base, details: dict)`. This function will create a new log entry.
2. **Integrate Logging:**
* Modify `be/app/crud/expense.py`: In `create_expense`, `update_expense`, `delete_expense`, call `create_financial_audit_log`. For updates, the `details` JSONB should contain `{"before": {...}, "after": {...}}`.
* Modify `be/app/crud/settlement.py`: Do the same for `create_settlement`, `update_settlement`, `delete_settlement`.
* Modify `be/app/crud/settlement_activity.py`: Do the same for `create_settlement_activity`.
3. **API (`be/app/api/v1/endpoints/history.py` - new file):**
* Create a new endpoint `GET /history/financial/group/{group_id}` to view the audit log for a group.
* Create a new endpoint `GET /history/financial/user/me` for a user's personal financial history.
#### **Task 2.2: Implement Archiving**
* **Goal:** Allow users to archive lists instead of permanently deleting them.
* **Tasks:**
1. **CRUD (`be/app/crud/list.py`):**
* Rename `delete_list` to `archive_list`. Instead of `db.delete(list_db)`, it should set `list_db.archived_at = datetime.now(timezone.utc)`.
* Modify `get_lists_for_user` to filter out archived lists by default: `.where(ListModel.archived_at.is_(None))`.
2. **API (`be/app/api/v1/endpoints/lists.py`):**
* Update the `DELETE /{list_id}` endpoint to call `archive_list`.
* Create a new endpoint `GET /archived` to fetch archived lists for the user.
* Create a new endpoint `POST /{list_id}/unarchive` to set `archived_at` back to `NULL`.
#### **Task 2.3: Implement Chore Subtasks & Unmarking Completion**
* **Goal:** Allow chores to have a hierarchy and for completion to be reversible.
* **Tasks:**
1. **Schemas (`be/app/schemas/chore.py`):**
* Update `ChorePublic` and `ChoreCreate` schemas to include `parent_chore_id: Optional[int]` and `child_chores: List[ChorePublic] = []`.
2. **CRUD (`be/app/crud/chore.py`):**
* Modify `create_chore` and `update_chore` to handle the `parent_chore_id`.
* In `update_chore_assignment`, enhance the `is_complete=False` logic. When a chore is re-opened, log it to the history. Decide on the policy for the parent chore's `next_due_date` (recommendation: do not automatically roll it back; let the user adjust it manually if needed).
3. **API (`be/app/api/v1/endpoints/chores.py`):**
* Update the `POST` and `PUT` endpoints for chores to accept `parent_chore_id`.
* The `PUT /assignments/{assignment_id}` endpoint already supports setting `is_complete`. Ensure it correctly calls the updated CRUD logic.
#### **Task 2.4: Implement List Categories**
* **Goal:** Allow items to be categorized for better organization.
* **Tasks:**
1. **Schemas (`be/app/schemas/category.py` - new file):**
* Create `CategoryCreate`, `CategoryUpdate`, `CategoryPublic`.
2. **CRUD (`be/app/crud/category.py` - new file):**
* Implement full CRUD functions for categories (`create_category`, `get_user_categories`, `update_category`, `delete_category`).
3. **API (`be/app/api/v1/endpoints/categories.py` - new file):**
* Create endpoints for `GET /`, `POST /`, `PUT /{id}`, `DELETE /{id}` for categories.
4. **Item Integration:**
* Update `ItemCreate` and `ItemUpdate` schemas in `be/app/schemas/item.py` to include `category_id: Optional[int]`.
* Update `crud_item.create_item` and `crud_item.update_item` to handle setting the `category_id`.
#### **Task 2.5: Enhance OCR for Receipts**
* **skipped**
#### **Task 2.6: Implement "Continue as Guest"**
* **Goal:** Allow users to use the app without creating a full account.
* **Tasks:**
1. **DB Model (`be/app/models.py`):**
* Add `is_guest = Column(Boolean, default=False, nullable=False)` to the `User` model.
2. **Auth (`be/app/api/auth/guest.py` - new file):**
* Create a new router for guest functionality.
* Implement a `POST /auth/guest` endpoint. This endpoint will:
* Create a new user with a unique but temporary-looking email (e.g., `guest_{uuid}@guest.mitlist.app`).
* Set `is_guest=True`.
* Generate and return JWT tokens for this guest user, just like a normal login.
3. **Claim Account (`be/app/api/auth/guest.py`):**
* Implement a `POST /auth/guest/claim` endpoint (requires auth). This endpoint will take a new email and password, update the `is_guest=False`, set the new credentials, and mark the email for verification.
#### **Task 2.7: Implement Redis**
* **Goal:** Integrate Redis for caching to improve performance.
* **Tasks:**
1. **Dependencies (`be/requirements.txt`):** Add `redis`.
2. **Configuration (`be/app/config.py`):** Add `REDIS_URL` to settings.
3. **Connection (`be/app/core/redis.py` - new file):** Create a Redis connection pool.
4. **Caching (`be/app/core/cache.py` - new file):** Implement a simple caching decorator.
```python
# Example decorator
def cache(expire_time: int = 3600):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
# ... logic to check cache, return if hit ...
# ... if miss, call func, store result in cache ...
return result
return wrapper
return decorator
```
5. **Apply Caching:** Apply the `@cache` decorator to read-heavy, non-volatile CRUD functions like `crud_group.get_group_by_id`.
---
### **Phase 3: Frontend Implementation**
Implement the UI for the new features, using your Valerie UI components.
#### **Task 3.1: Implement Archiving UI**
* **Goal:** Allow users to archive and view archived lists.
* **Files to Modify:** `fe/src/pages/ListsPage.vue`, `fe/src/stores/listStore.ts` (if you create one).
* **Tasks:**
1. Change the "Delete" action on lists to "Archive".
2. Add a toggle/filter to show archived lists.
3. When viewing archived lists, show an "Unarchive" button.
#### **Task 3.2: Implement Subtasks and Unmarking UI**
* **Goal:** Update the chore interface for subtasks and undoing completion.
* **Files to Modify:** `fe/src/pages/ChoresPage.vue`, `fe/src/components/ChoreItem.vue` (if it exists).
* **Tasks:**
1. Modify the chore list to be a nested/tree view to display parent-child relationships.
2. Update the chore creation/edit modal to include a "Parent Chore" dropdown.
3. On completed chores, change the "Completed" checkmark to an "Undo" button. Clicking it should call the API to set `is_complete` to `false`.
#### **Task 3.3: Implement Category Management and Supermarkt Mode**
* **Goal:** Add category features and the special "Supermarkt Mode".
* **Files to Modify:** `fe/src/pages/ListDetailPage.vue`, `fe/src/components/Item.vue`.
* **Tasks:**
1. Create a new page/modal for managing categories (CRUD).
2. In the `ListDetailPage`, add a "Category" dropdown when adding/editing an item.
3. Display items grouped by category.
4. **Supermarkt Mode:**
* Add a toggle button on the `ListDetailPage` to enter "Supermarkt Mode".
* When an item is checked, apply a temporary CSS class to other items in the same category.
* Ensure the price input field appears next to checked items.
* Add a `VProgressBar` at the top, with `value` bound to `completedItems.length` and `max` bound to `totalItems.length`.
#### **Task 3.4: Implement Time Tracking UI**
* **Goal:** Allow users to track time on chores.
* **Files to Modify:** `fe/src/pages/ChoresPage.vue`.
* **Tasks:**
1. Add a "Start/Stop" timer button on each chore assignment.
2. Clicking "Start" sends a `POST /time_entries` request.
3. Clicking "Stop" sends a `PUT /time_entries/{id}` request.
4. Display the total time spent on the chore.
#### **Task 3.5: Implement Guest Flow**
* **Goal:** Provide a seamless entry point for new users.
* **Files to Modify:** `fe/src/pages/LoginPage.vue`, `fe/src/stores/auth.ts`, `fe/src/router/index.ts`.
* **Tasks:**
1. On the `LoginPage`, add a "Continue as Guest" button.
2. This button calls a new `authStore.loginAsGuest()` action.
3. The action hits the `POST /auth/guest` endpoint, receives tokens, and stores them.
4. The router logic needs adjustment to handle guest users. You might want to protect certain pages (like "Account Settings") even from guests.
5. Add a persistent banner in the UI for guest users: "You are using a guest account. **Sign up** to save your data."
</file>
```
**Final Note:** This is a comprehensive roadmap. Each major task can be broken down further into smaller sub-tasks. Good luck with the implementation

37
.cursor/rules/vue.mdc Normal file
View File

@ -0,0 +1,37 @@
---
description:
globs:
alwaysApply: true
---
You have extensive expertise in Vue 3, TypeScript, Node.js, Vite, Vue Router, Pinia, VueUse, and CSS. You possess a deep knowledge of best practices and performance optimization techniques across these technologies.
Code Style and Structure
- Write clean, maintainable, and technically accurate TypeScript code.
- Emphasize iteration and modularization and minimize code duplication.
- Prefer Composition API <script setup> style.
- Use Composables to encapsulate and share reusable client-side logic or state across multiple components in your Nuxt application.
Fetching Data
1. Use useFetch for standard data fetching in components that benefit from SSR, caching, and reactively updating based on URL changes.
2. Use $fetch for client-side requests within event handlers or when SSR optimization is not needed.
3. Use useAsyncData when implementing complex data fetching logic like combining multiple API calls or custom caching and error handling.
4. Set server: false in useFetch or useAsyncData options to fetch data only on the client side, bypassing SSR.
5. Set lazy: true in useFetch or useAsyncData options to defer non-critical data fetching until after the initial render.
Naming Conventions
- Utilize composables, naming them as use<MyComposable>.
- Use **PascalCase** for component file names (e.g., components/MyComponent.vue).
- Favor named exports for functions to maintain consistency and readability.
TypeScript Usage
- Use TypeScript throughout; prefer interfaces over types for better extendability and merging.
- Avoid enums, opting for maps for improved type safety and flexibility.
- Use functional components with TypeScript interfaces.
UI and Styling.
- Implement responsive design; use a mobile-first approach.

View File

@ -0,0 +1,75 @@
"""Add chore history and scheduling tables
Revision ID: 05bf96a9e18b
Revises: 91d00c100f5b
Create Date: 2025-06-08 00:41:10.516324
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '05bf96a9e18b'
down_revision: Union[str, None] = '91d00c100f5b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('chore_history',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('chore_id', sa.Integer(), nullable=True),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.Column('event_type', sa.Enum('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'REOPENED', 'ASSIGNED', 'UNASSIGNED', 'REASSIGNED', 'SCHEDULE_GENERATED', 'DUE_DATE_CHANGED', 'DETAILS_CHANGED', name='chorehistoryeventtypeenum'), nullable=False),
sa.Column('event_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('changed_by_user_id', sa.Integer(), nullable=True),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['changed_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['chore_id'], ['chores.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chore_history_chore_id'), 'chore_history', ['chore_id'], unique=False)
op.create_index(op.f('ix_chore_history_group_id'), 'chore_history', ['group_id'], unique=False)
op.create_index(op.f('ix_chore_history_id'), 'chore_history', ['id'], unique=False)
op.create_table('chore_assignment_history',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('assignment_id', sa.Integer(), nullable=False),
sa.Column('event_type', sa.Enum('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'REOPENED', 'ASSIGNED', 'UNASSIGNED', 'REASSIGNED', 'SCHEDULE_GENERATED', 'DUE_DATE_CHANGED', 'DETAILS_CHANGED', name='chorehistoryeventtypeenum'), nullable=False),
sa.Column('event_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('changed_by_user_id', sa.Integer(), nullable=True),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['assignment_id'], ['chore_assignments.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['changed_by_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chore_assignment_history_assignment_id'), 'chore_assignment_history', ['assignment_id'], unique=False)
op.create_index(op.f('ix_chore_assignment_history_id'), 'chore_assignment_history', ['id'], unique=False)
op.drop_index('ix_apscheduler_jobs_next_run_time', table_name='apscheduler_jobs')
op.drop_table('apscheduler_jobs')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('apscheduler_jobs',
sa.Column('id', sa.VARCHAR(length=191), autoincrement=False, nullable=False),
sa.Column('next_run_time', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
sa.Column('job_state', postgresql.BYTEA(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name='apscheduler_jobs_pkey')
)
op.create_index('ix_apscheduler_jobs_next_run_time', 'apscheduler_jobs', ['next_run_time'], unique=False)
op.drop_index(op.f('ix_chore_assignment_history_id'), table_name='chore_assignment_history')
op.drop_index(op.f('ix_chore_assignment_history_assignment_id'), table_name='chore_assignment_history')
op.drop_table('chore_assignment_history')
op.drop_index(op.f('ix_chore_history_id'), table_name='chore_history')
op.drop_index(op.f('ix_chore_history_group_id'), table_name='chore_history')
op.drop_index(op.f('ix_chore_history_chore_id'), table_name='chore_history')
op.drop_table('chore_history')
# ### end Alembic commands ###

View File

@ -0,0 +1,91 @@
"""feature_updates_phase1
Revision ID: bdf7427ccfa3
Revises: 05bf96a9e18b
Create Date: 2025-06-09 18:00:11.083651
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'bdf7427ccfa3'
down_revision: Union[str, None] = '05bf96a9e18b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('financial_audit_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('action_type', sa.String(), nullable=False),
sa.Column('entity_type', sa.String(), nullable=False),
sa.Column('entity_id', sa.Integer(), nullable=False),
sa.Column('details', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_financial_audit_log_action_type'), 'financial_audit_log', ['action_type'], unique=False)
op.create_index(op.f('ix_financial_audit_log_id'), 'financial_audit_log', ['id'], unique=False)
op.create_table('categories',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name', 'user_id', 'group_id', name='uq_category_scope')
)
op.create_index(op.f('ix_categories_id'), 'categories', ['id'], unique=False)
op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=False)
op.create_table('time_entries',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('chore_assignment_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('start_time', sa.DateTime(timezone=True), nullable=False),
sa.Column('end_time', sa.DateTime(timezone=True), nullable=True),
sa.Column('duration_seconds', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['chore_assignment_id'], ['chore_assignments.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_time_entries_id'), 'time_entries', ['id'], unique=False)
op.add_column('chores', sa.Column('parent_chore_id', sa.Integer(), nullable=True))
op.create_index(op.f('ix_chores_parent_chore_id'), 'chores', ['parent_chore_id'], unique=False)
op.create_foreign_key(None, 'chores', 'chores', ['parent_chore_id'], ['id'])
op.add_column('items', sa.Column('category_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'items', 'categories', ['category_id'], ['id'])
op.add_column('lists', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_lists_archived_at'), 'lists', ['archived_at'], unique=False)
op.add_column('users', sa.Column('is_guest', sa.Boolean(), nullable=False, server_default='f'))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'is_guest')
op.drop_index(op.f('ix_lists_archived_at'), table_name='lists')
op.drop_column('lists', 'archived_at')
op.drop_constraint(None, 'items', type_='foreignkey')
op.drop_column('items', 'category_id')
op.drop_constraint(None, 'chores', type_='foreignkey')
op.drop_index(op.f('ix_chores_parent_chore_id'), table_name='chores')
op.drop_column('chores', 'parent_chore_id')
op.drop_index(op.f('ix_time_entries_id'), table_name='time_entries')
op.drop_table('time_entries')
op.drop_index(op.f('ix_categories_name'), table_name='categories')
op.drop_index(op.f('ix_categories_id'), table_name='categories')
op.drop_table('categories')
op.drop_index(op.f('ix_financial_audit_log_id'), table_name='financial_audit_log')
op.drop_index(op.f('ix_financial_audit_log_action_type'), table_name='financial_audit_log')
op.drop_table('financial_audit_log')
# ### end Alembic commands ###

View File

@ -0,0 +1,51 @@
"""add_updated_at_and_version_to_groups
Revision ID: c693ade3601c
Revises: bdf7427ccfa3
Create Date: 2025-06-09 19:22:36.244072
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'c693ade3601c'
down_revision: Union[str, None] = 'bdf7427ccfa3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_apscheduler_jobs_next_run_time', table_name='apscheduler_jobs')
op.drop_table('apscheduler_jobs')
op.add_column('groups', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False))
op.add_column('groups', sa.Column('version', sa.Integer(), server_default='1', nullable=False))
op.alter_column('users', 'is_guest',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('users', 'is_guest',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.drop_column('groups', 'version')
op.drop_column('groups', 'updated_at')
op.create_table('apscheduler_jobs',
sa.Column('id', sa.VARCHAR(length=191), autoincrement=False, nullable=False),
sa.Column('next_run_time', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
sa.Column('job_state', postgresql.BYTEA(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name='apscheduler_jobs_pkey')
)
op.create_index('ix_apscheduler_jobs_next_run_time', 'apscheduler_jobs', ['next_run_time'], unique=False)
# ### end Alembic commands ###

View File

@ -1,12 +1,5 @@
# app/api/api_router.py
from fastapi import APIRouter
from app.api.v1.api import api_router_v1 # Import the v1 router
from app.api.v1.api import api_router_v1
api_router = APIRouter()
# Include versioned routers here, adding the /api prefix
api_router.include_router(api_router_v1, prefix="/v1") # Mounts v1 endpoints under /api/v1/...
# Add other API versions later
# e.g., api_router.include_router(api_router_v2, prefix="/v2")
api_router.include_router(api_router_v1, prefix="/v1")

65
be/app/api/auth/guest.py Normal file
View File

@ -0,0 +1,65 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from app import models
from app.schemas.user import UserCreate, UserClaim, UserPublic
from app.schemas.auth import Token
from app.database import get_session
from app.auth import current_active_user, get_jwt_strategy, get_refresh_jwt_strategy
from app.core.security import get_password_hash
from app.crud import user as crud_user
router = APIRouter()
@router.post("/guest", response_model=Token)
async def create_guest_user(db: AsyncSession = Depends(get_session)):
"""
Creates a new guest user.
"""
guest_email = f"guest_{uuid.uuid4()}@guest.mitlist.app"
guest_password = uuid.uuid4().hex
user_in = UserCreate(email=guest_email, password=guest_password)
user = await crud_user.create_user(db, user_in=user_in, is_guest=True)
# Use the same JWT strategy as regular login to generate both access and refresh tokens
access_strategy = get_jwt_strategy()
refresh_strategy = get_refresh_jwt_strategy()
access_token = await access_strategy.write_token(user)
refresh_token = await refresh_strategy.write_token(user)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer"
}
@router.post("/guest/claim", response_model=UserPublic)
async def claim_guest_account(
claim_in: UserClaim,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
"""
Claims a guest account, converting it to a full user.
"""
if not current_user.is_guest:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Not a guest account.")
existing_user = await crud_user.get_user_by_email(db, email=claim_in.email)
if existing_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered.")
hashed_password = get_password_hash(claim_in.password)
current_user.email = claim_in.email
current_user.hashed_password = hashed_password
current_user.is_guest = False
current_user.is_verified = False # Require email verification
db.add(current_user)
await db.commit()
await db.refresh(current_user)
return current_user

26
be/app/api/auth/jwt.py Normal file
View File

@ -0,0 +1,26 @@
from fastapi import APIRouter
from app.auth import auth_backend, fastapi_users
from app.schemas.user import UserCreate, UserPublic, UserUpdate
router = APIRouter()
router.include_router(
fastapi_users.get_auth_router(auth_backend),
prefix="/jwt",
tags=["auth"],
)
router.include_router(
fastapi_users.get_register_router(UserPublic, UserCreate),
prefix="",
tags=["auth"],
)
router.include_router(
fastapi_users.get_reset_password_router(),
prefix="",
tags=["auth"],
)
router.include_router(
fastapi_users.get_verify_router(UserPublic),
prefix="",
tags=["auth"],
)

View File

@ -1,11 +1,12 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from fastapi import APIRouter, Depends, Request, HTTPException, status
from fastapi.responses import RedirectResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_transactional_session
from app.models import User
from app.auth import oauth, fastapi_users, auth_backend, get_jwt_strategy, get_refresh_jwt_strategy
from app.config import settings
from fastapi.security import OAuth2PasswordRequestForm
router = APIRouter()
@ -18,30 +19,26 @@ async def google_callback(request: Request, db: AsyncSession = Depends(get_trans
token_data = await oauth.google.authorize_access_token(request)
user_info = await oauth.google.parse_id_token(request, token_data)
# Check if user exists
existing_user = (await db.execute(select(User).where(User.email == user_info['email']))).scalar_one_or_none()
user_to_login = existing_user
if not existing_user:
# Create new user
new_user = User(
email=user_info['email'],
name=user_info.get('name', user_info.get('email')),
is_verified=True, # Email is verified by Google
is_verified=True,
is_active=True
)
db.add(new_user)
await db.flush() # Use flush instead of commit since we're in a transaction
await db.flush()
user_to_login = new_user
# Generate JWT tokens using the new backend
access_strategy = get_jwt_strategy()
refresh_strategy = get_refresh_jwt_strategy()
access_token = await access_strategy.write_token(user_to_login)
refresh_token = await refresh_strategy.write_token(user_to_login)
# Redirect to frontend with tokens
redirect_url = f"{settings.FRONTEND_URL}/auth/callback?access_token={access_token}&refresh_token={refresh_token}"
return RedirectResponse(url=redirect_url)
@ -61,12 +58,10 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_transa
if 'email' not in user_info:
return RedirectResponse(url=f"{settings.FRONTEND_URL}/auth/callback?error=apple_email_missing")
# Check if user exists
existing_user = (await db.execute(select(User).where(User.email == user_info['email']))).scalar_one_or_none()
user_to_login = existing_user
if not existing_user:
# Create new user
name_info = user_info.get('name', {})
first_name = name_info.get('firstName', '')
last_name = name_info.get('lastName', '')
@ -75,21 +70,44 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_transa
new_user = User(
email=user_info['email'],
name=full_name,
is_verified=True, # Email is verified by Apple
is_verified=True,
is_active=True
)
db.add(new_user)
await db.flush() # Use flush instead of commit since we're in a transaction
await db.flush()
user_to_login = new_user
# Generate JWT tokens using the new backend
access_strategy = get_jwt_strategy()
refresh_strategy = get_refresh_jwt_strategy()
access_token = await access_strategy.write_token(user_to_login)
refresh_token = await refresh_strategy.write_token(user_to_login)
# Redirect to frontend with tokens
redirect_url = f"{settings.FRONTEND_URL}/auth/callback?access_token={access_token}&refresh_token={refresh_token}"
return RedirectResponse(url=redirect_url)
@router.post('/jwt/refresh')
async def refresh_jwt_token(request: Request):
data = await request.json()
refresh_token = data.get('refresh_token')
if not refresh_token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token")
refresh_strategy = get_refresh_jwt_strategy()
try:
user = await refresh_strategy.read_token(refresh_token, None)
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
access_strategy = get_jwt_strategy()
access_token = await access_strategy.write_token(user)
new_refresh_token = await refresh_strategy.write_token(user)
return JSONResponse({
"access_token": access_token,
"refresh_token": new_refresh_token,
"token_type": "bearer"
})

View File

@ -9,6 +9,10 @@ from app.api.v1.endpoints import ocr
from app.api.v1.endpoints import costs
from app.api.v1.endpoints import financials
from app.api.v1.endpoints import chores
from app.api.v1.endpoints import history
from app.api.v1.endpoints import categories
from app.api.v1.endpoints import users
from app.api.auth import oauth, guest, jwt
api_router_v1 = APIRouter()
@ -21,5 +25,9 @@ api_router_v1.include_router(ocr.router, prefix="/ocr", tags=["OCR"])
api_router_v1.include_router(costs.router, prefix="/costs", tags=["Costs"])
api_router_v1.include_router(financials.router, prefix="/financials", tags=["Financials"])
api_router_v1.include_router(chores.router, prefix="/chores", tags=["Chores"])
# Add other v1 endpoint routers here later
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
api_router_v1.include_router(history.router, prefix="/history", tags=["History"])
api_router_v1.include_router(categories.router, prefix="/categories", tags=["Categories"])
api_router_v1.include_router(oauth.router, prefix="/auth", tags=["Auth"])
api_router_v1.include_router(guest.router, prefix="/auth", tags=["Auth"])
api_router_v1.include_router(jwt.router, prefix="/auth", tags=["Auth"])
api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])

View File

@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Optional
from app import models
from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryPublic
from app.database import get_session
from app.auth import current_active_user
from app.crud import category as crud_category, group as crud_group
router = APIRouter()
@router.post("/", response_model=CategoryPublic)
async def create_category(
category_in: CategoryCreate,
group_id: Optional[int] = None,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
if group_id:
is_member = await crud_group.is_user_member(db, user_id=current_user.id, group_id=group_id)
if not is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member of this group")
return await crud_category.create_category(db=db, category_in=category_in, user_id=current_user.id, group_id=group_id)
@router.get("/", response_model=List[CategoryPublic])
async def read_categories(
group_id: Optional[int] = None,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
if group_id:
is_member = await crud_group.is_user_member(db, user_id=current_user.id, group_id=group_id)
if not is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member of this group")
return await crud_category.get_group_categories(db=db, group_id=group_id)
return await crud_category.get_user_categories(db=db, user_id=current_user.id)
@router.put("/{category_id}", response_model=CategoryPublic)
async def update_category(
category_id: int,
category_in: CategoryUpdate,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
db_category = await crud_category.get_category(db, category_id=category_id)
if not db_category:
raise HTTPException(status_code=404, detail="Category not found")
if db_category.user_id != current_user.id:
if not db_category.group_id:
raise HTTPException(status_code=403, detail="Not your category")
is_member = await crud_group.is_user_member(db, user_id=current_user.id, group_id=db_category.group_id)
if not is_member:
raise HTTPException(status_code=403, detail="Not a member of this group")
return await crud_category.update_category(db=db, db_category=db_category, category_in=category_in)
@router.delete("/{category_id}", response_model=CategoryPublic)
async def delete_category(
category_id: int,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
db_category = await crud_category.get_category(db, category_id=category_id)
if not db_category:
raise HTTPException(status_code=404, detail="Category not found")
if db_category.user_id != current_user.id:
if not db_category.group_id:
raise HTTPException(status_code=403, detail="Not your category")
is_member = await crud_group.is_user_member(db, user_id=current_user.id, group_id=db_category.group_id)
if not is_member:
raise HTTPException(status_code=403, detail="Not a member of this group")
return await crud_category.delete_category(db=db, db_category=db_category)

View File

@ -1,21 +1,28 @@
# app/api/v1/endpoints/chores.py
import logging
from typing import List as PyList, Optional
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status, Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_transactional_session, get_session
from app.auth import current_active_user
from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum
from app.schemas.chore import ChoreCreate, ChoreUpdate, ChorePublic, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic
from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum, TimeEntry
from app.schemas.chore import (
ChoreCreate, ChoreUpdate, ChorePublic,
ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic,
ChoreHistoryPublic, ChoreAssignmentHistoryPublic
)
from app.schemas.time_entry import TimeEntryPublic
from app.crud import chore as crud_chore
from app.crud import history as crud_history
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
logger = logging.getLogger(__name__)
router = APIRouter()
# Add this new endpoint before the personal chores section
@router.get(
"/all",
response_model=PyList[ChorePublic],
@ -23,13 +30,12 @@ router = APIRouter()
tags=["Chores"]
)
async def list_all_chores(
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all chores (personal and group) for the current user in a single optimized request."""
logger.info(f"User {current_user.email} listing all their chores")
# Use the optimized function that reduces database queries
all_chores = await crud_chore.get_all_user_chores(db=db, user_id=current_user.id)
return all_chores
@ -130,14 +136,12 @@ async def delete_personal_chore(
"""Deletes a personal chore for the current user."""
logger.info(f"User {current_user.email} deleting personal chore ID: {chore_id}")
try:
# First, verify it's a personal chore belonging to the user
chore_to_delete = await crud_chore.get_chore_by_id(db, chore_id)
if not chore_to_delete or chore_to_delete.type != ChoreTypeEnum.personal or chore_to_delete.created_by_id != current_user.id:
raise ChoreNotFoundError(chore_id=chore_id, detail="Personal chore not found or not owned by user.")
success = await crud_chore.delete_chore(db=db, chore_id=chore_id, user_id=current_user.id, group_id=None)
if not success:
# This case should be rare if the above check passes and DB is consistent
raise ChoreNotFoundError(chore_id=chore_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
except ChoreNotFoundError as e:
@ -151,7 +155,6 @@ async def delete_personal_chore(
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
# --- Group Chores Endpoints ---
# (These would be similar to what you might have had before, but now explicitly part of this router)
@router.post(
"/groups/{group_id}/chores",
@ -230,7 +233,6 @@ async def update_group_chore(
if chore_in.group_id is not None and chore_in.group_id != group_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Chore's group_id if provided must match path group_id ({group_id}).")
# Ensure chore_in has the correct type for the CRUD operation
chore_payload = chore_in.model_copy(update={"type": ChoreTypeEnum.group, "group_id": group_id} if chore_in.type is None else {"group_id": group_id})
try:
@ -266,15 +268,12 @@ async def delete_group_chore(
"""Deletes a chore from a group, ensuring user has permission."""
logger.info(f"User {current_user.email} deleting chore ID {chore_id} from group {group_id}")
try:
# Verify chore exists and belongs to the group before attempting deletion via CRUD
# This gives a more precise error if the chore exists but isn't in this group.
chore_to_delete = await crud_chore.get_chore_by_id_and_group(db, chore_id, group_id, current_user.id) # checks permission too
if not chore_to_delete : # get_chore_by_id_and_group will raise PermissionDeniedError if user not member
raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id)
success = await crud_chore.delete_chore(db=db, chore_id=chore_id, user_id=current_user.id, group_id=group_id)
if not success:
# This case should be rare if the above check passes and DB is consistent
raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
except ChoreNotFoundError as e:
@ -326,7 +325,7 @@ async def create_chore_assignment(
)
async def list_my_assignments(
include_completed: bool = False,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all chore assignments for the current user."""
@ -345,7 +344,7 @@ async def list_my_assignments(
)
async def list_chore_assignments(
chore_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all assignments for a specific chore."""
@ -451,3 +450,182 @@ async def complete_chore_assignment(
except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError completing assignment {assignment_id} for {current_user.email}: {e.detail}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
# === CHORE HISTORY ENDPOINTS ===
@router.get(
"/{chore_id}/history",
response_model=PyList[ChoreHistoryPublic],
summary="Get Chore History",
tags=["Chores", "History"]
)
async def get_chore_history(
chore_id: int,
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves the history of a specific chore."""
chore = await crud_chore.get_chore_by_id(db, chore_id)
if not chore:
raise ChoreNotFoundError(chore_id=chore_id)
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
raise PermissionDeniedError("You can only view history for your own personal chores.")
if chore.type == ChoreTypeEnum.group:
is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id)
if not is_member:
raise PermissionDeniedError("You must be a member of the group to view this chore's history.")
logger.info(f"User {current_user.email} getting history for chore {chore_id}")
return await crud_history.get_chore_history(db=db, chore_id=chore_id)
@router.get(
"/assignments/{assignment_id}/history",
response_model=PyList[ChoreAssignmentHistoryPublic],
summary="Get Chore Assignment History",
tags=["Chore Assignments", "History"]
)
async def get_chore_assignment_history(
assignment_id: int,
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves the history of a specific chore assignment."""
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
if not assignment:
raise ChoreNotFoundError(assignment_id=assignment_id)
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
if not chore:
raise ChoreNotFoundError(chore_id=assignment.chore_id)
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
raise PermissionDeniedError("You can only view history for assignments of your own personal chores.")
if chore.type == ChoreTypeEnum.group:
is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id)
if not is_member:
raise PermissionDeniedError("You must be a member of the group to view this assignment's history.")
logger.info(f"User {current_user.email} getting history for assignment {assignment_id}")
return await crud_history.get_assignment_history(db=db, assignment_id=assignment_id)
# === TIME ENTRY ENDPOINTS ===
@router.get(
"/assignments/{assignment_id}/time-entries",
response_model=PyList[TimeEntryPublic],
summary="Get Time Entries",
tags=["Time Tracking"]
)
async def get_time_entries_for_assignment(
assignment_id: int,
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all time entries for a specific chore assignment."""
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
if not assignment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found")
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
if not chore:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chore not found")
# Permission check
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
if chore.type == ChoreTypeEnum.group:
is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id)
if not is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
# For now, return time entries for the current user only
time_entries = await db.execute(
select(TimeEntry)
.where(TimeEntry.chore_assignment_id == assignment_id)
.where(TimeEntry.user_id == current_user.id)
.order_by(TimeEntry.start_time.desc())
)
return time_entries.scalars().all()
@router.post(
"/assignments/{assignment_id}/time-entries",
response_model=TimeEntryPublic,
status_code=status.HTTP_201_CREATED,
summary="Start Time Entry",
tags=["Time Tracking"]
)
async def start_time_entry(
assignment_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Starts a new time entry for a chore assignment."""
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
if not assignment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found")
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
if not chore:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chore not found")
# Permission check - only assigned user can track time
if assignment.assigned_to_user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only assigned user can track time")
# Check if there's already an active time entry
existing_active = await db.execute(
select(TimeEntry)
.where(TimeEntry.chore_assignment_id == assignment_id)
.where(TimeEntry.user_id == current_user.id)
.where(TimeEntry.end_time.is_(None))
)
if existing_active.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Time entry already active")
# Create new time entry
time_entry = TimeEntry(
chore_assignment_id=assignment_id,
user_id=current_user.id,
start_time=datetime.now(timezone.utc)
)
db.add(time_entry)
await db.commit()
await db.refresh(time_entry)
return time_entry
@router.put(
"/time-entries/{time_entry_id}",
response_model=TimeEntryPublic,
summary="Stop Time Entry",
tags=["Time Tracking"]
)
async def stop_time_entry(
time_entry_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Stops an active time entry."""
time_entry = await db.get(TimeEntry, time_entry_id)
if not time_entry:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Time entry not found")
if time_entry.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
if time_entry.end_time:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Time entry already stopped")
# Stop the time entry
end_time = datetime.now(timezone.utc)
time_entry.end_time = end_time
time_entry.duration_seconds = int((end_time - time_entry.start_time).total_seconds())
await db.commit()
await db.refresh(time_entry)
return time_entry

View File

@ -1,4 +1,4 @@
# app/api/v1/endpoints/costs.py
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
@ -18,14 +18,14 @@ from app.models import (
UserGroup as UserGroupModel,
SplitTypeEnum,
ExpenseSplit as ExpenseSplitModel,
Settlement as SettlementModel,
SettlementActivity as SettlementActivityModel # Added
SettlementActivity as SettlementActivityModel,
Settlement as SettlementModel
)
from app.schemas.cost import ListCostSummary, GroupBalanceSummary, UserCostShare, UserBalanceDetail, SuggestedSettlement
from app.schemas.expense import ExpenseCreate
from app.crud import list as crud_list
from app.crud import expense as crud_expense
from app.core.exceptions import ListNotFoundError, ListPermissionError, UserNotFoundError, GroupNotFoundError
from app.core.exceptions import ListNotFoundError, ListPermissionError, GroupNotFoundError
logger = logging.getLogger(__name__)
router = APIRouter()

View File

@ -1,4 +1,3 @@
# app/api/v1/endpoints/groups.py
import logging
from typing import List
@ -7,14 +6,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session, get_session
from app.auth import current_active_user
from app.models import User as UserModel, UserRoleEnum # Import model and enum
from app.schemas.group import GroupCreate, GroupPublic
from app.models import User as UserModel, UserRoleEnum
from app.schemas.group import GroupCreate, GroupPublic, GroupScheduleGenerateRequest
from app.schemas.invite import InviteCodePublic
from app.schemas.message import Message # For simple responses
from app.schemas.list import ListPublic, ListDetail
from app.schemas.message import Message
from app.schemas.list import ListDetail
from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentPublic
from app.schemas.user import UserPublic
from app.crud import group as crud_group
from app.crud import invite as crud_invite
from app.crud import list as crud_list
from app.crud import history as crud_history
from app.crud import schedule as crud_schedule
from app.core.exceptions import (
GroupNotFoundError,
GroupPermissionError,
@ -42,8 +45,6 @@ async def create_group(
"""Creates a new group, adding the creator as the owner."""
logger.info(f"User {current_user.email} creating group: {group_in.name}")
created_group = await crud_group.create_group(db=db, group_in=group_in, creator_id=current_user.id)
# Load members explicitly if needed for the response (optional here)
# created_group = await crud_group.get_group_by_id(db, created_group.id)
return created_group
@ -54,7 +55,7 @@ async def create_group(
tags=["Groups"]
)
async def read_user_groups(
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all groups the current user is a member of."""
@ -71,12 +72,11 @@ async def read_user_groups(
)
async def read_group(
group_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves details for a specific group, including members, if the user is part of it."""
logger.info(f"User {current_user.email} requesting details for group ID: {group_id}")
# Check if user is a member first
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
if not is_member:
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
@ -89,6 +89,31 @@ async def read_group(
return group
@router.get(
"/{group_id}/members",
response_model=List[UserPublic],
summary="Get Group Members",
tags=["Groups"]
)
async def read_group_members(
group_id: int,
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all members of a specific group, if the user is part of it."""
logger.info(f"User {current_user.email} requesting members for group ID: {group_id}")
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
if not is_member:
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
raise GroupMembershipError(group_id, "view group members")
group = await crud_group.get_group_by_id(db=db, group_id=group_id)
if not group:
logger.error(f"Group {group_id} requested by member {current_user.email} not found (data inconsistency?)")
raise GroupNotFoundError(group_id)
return [member_assoc.user for member_assoc in group.member_associations]
@router.post(
"/{group_id}/invites",
@ -105,12 +130,10 @@ async def create_group_invite(
logger.info(f"User {current_user.email} attempting to create invite for group {group_id}")
user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
# --- Permission Check (MVP: Owner only) ---
if user_role != UserRoleEnum.owner:
logger.warning(f"Permission denied: User {current_user.email} (role: {user_role}) cannot create invite for group {group_id}")
raise GroupPermissionError(group_id, "create invites")
# Check if group exists (implicitly done by role check, but good practice)
group = await crud_group.get_group_by_id(db, group_id)
if not group:
raise GroupNotFoundError(group_id)
@ -118,7 +141,6 @@ async def create_group_invite(
invite = await crud_invite.create_invite(db=db, group_id=group_id, creator_id=current_user.id)
if not invite:
logger.error(f"Failed to generate unique invite code for group {group_id}")
# This case should ideally be covered by exceptions from create_invite now
raise InviteCreationError(group_id)
logger.info(f"User {current_user.email} created invite code for group {group_id}")
@ -132,26 +154,20 @@ async def create_group_invite(
)
async def get_group_active_invite(
group_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves the active invite code for the group. Requires group membership (owner/admin to be stricter later if needed)."""
logger.info(f"User {current_user.email} attempting to get active invite for group {group_id}")
# Permission check: Ensure user is a member of the group to view invite code
# Using get_user_role_in_group which also checks membership indirectly
user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
if user_role is None: # Not a member
logger.warning(f"Permission denied: User {current_user.email} is not a member of group {group_id} and cannot view invite code.")
# More specific error or let GroupPermissionError handle if we want to be generic
raise GroupMembershipError(group_id, "view invite code for this group (not a member)")
# Fetch the active invite for the group
invite = await crud_invite.get_active_invite_for_group(db, group_id=group_id)
if not invite:
# This case means no active (non-expired, active=true) invite exists.
# The frontend can then prompt to generate one.
logger.info(f"No active invite code found for group {group_id} when requested by {current_user.email}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@ -159,7 +175,7 @@ async def get_group_active_invite(
)
logger.info(f"User {current_user.email} retrieved active invite code for group {group_id}")
return invite # Pydantic will convert InviteModel to InviteCodePublic
return invite
@router.delete(
"/{group_id}/leave",
@ -179,27 +195,22 @@ async def leave_group(
if user_role is None:
raise GroupMembershipError(group_id, "leave (you are not a member)")
# Check if owner is the last member
if user_role == UserRoleEnum.owner:
member_count = await crud_group.get_group_member_count(db, group_id)
if member_count <= 1:
# Delete the group since owner is the last member
logger.info(f"Owner {current_user.email} is the last member. Deleting group {group_id}")
await crud_group.delete_group(db, group_id)
return Message(detail="Group deleted as you were the last member")
# Proceed with removal for non-owner or if there are other members
deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=current_user.id)
if not deleted:
# Should not happen if role check passed, but handle defensively
logger.error(f"Failed to remove user {current_user.email} from group {group_id} despite being a member.")
raise GroupOperationError("Failed to leave group")
logger.info(f"User {current_user.email} successfully left group {group_id}")
return Message(detail="Successfully left the group")
# --- Optional: Remove Member Endpoint ---
@router.delete(
"/{group_id}/members/{user_id_to_remove}",
response_model=Message,
@ -216,21 +227,17 @@ async def remove_group_member(
logger.info(f"Owner {current_user.email} attempting to remove user {user_id_to_remove} from group {group_id}")
owner_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
# --- Permission Check ---
if owner_role != UserRoleEnum.owner:
logger.warning(f"Permission denied: User {current_user.email} (role: {owner_role}) cannot remove members from group {group_id}")
raise GroupPermissionError(group_id, "remove members")
# Prevent owner removing themselves via this endpoint
if current_user.id == user_id_to_remove:
raise GroupValidationError("Owner cannot remove themselves using this endpoint. Use 'Leave Group' instead.")
# Check if target user is actually in the group
target_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=user_id_to_remove)
if target_role is None:
raise GroupMembershipError(group_id, "remove this user (they are not a member)")
# Proceed with removal
deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=user_id_to_remove)
if not deleted:
@ -248,20 +255,67 @@ async def remove_group_member(
)
async def read_group_lists(
group_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all lists belonging to a specific group, if the user is a member."""
logger.info(f"User {current_user.email} requesting lists for group ID: {group_id}")
# Check if user is a member first
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
if not is_member:
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
raise GroupMembershipError(group_id, "view group lists")
# Get all lists for the user and filter by group_id
lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id)
group_lists = [list for list in lists if list.group_id == group_id]
return group_lists
@router.post(
"/{group_id}/chores/generate-schedule",
response_model=List[ChoreAssignmentPublic],
summary="Generate Group Chore Schedule",
tags=["Groups", "Chores"]
)
async def generate_group_chore_schedule(
group_id: int,
schedule_in: GroupScheduleGenerateRequest,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Generates a round-robin chore schedule for a group."""
logger.info(f"User {current_user.email} generating chore schedule for group {group_id}")
if not await crud_group.is_user_member(db, group_id, current_user.id):
raise GroupMembershipError(group_id, "generate chore schedule for this group")
try:
assignments = await crud_schedule.generate_group_chore_schedule(
db=db,
group_id=group_id,
start_date=schedule_in.start_date,
end_date=schedule_in.end_date,
user_id=current_user.id,
member_ids=schedule_in.member_ids,
)
return assignments
except Exception as e:
logger.error(f"Error generating schedule for group {group_id}: {e}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get(
"/{group_id}/chores/history",
response_model=List[ChoreHistoryPublic],
summary="Get Group Chore History",
tags=["Groups", "Chores", "History"]
)
async def get_group_chore_history(
group_id: int,
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all chore-related history for a specific group."""
logger.info(f"User {current_user.email} requesting chore history for group {group_id}")
if not await crud_group.is_user_member(db, group_id, current_user.id):
raise GroupMembershipError(group_id, "view chore history for this group")
return await crud_history.get_group_chore_history(db=db, group_id=group_id)

View File

@ -1,4 +1,3 @@
# app/api/v1/endpoints/health.py
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
@ -7,7 +6,6 @@ from sqlalchemy.sql import text
from app.database import get_transactional_session
from app.schemas.health import HealthStatus
from app.core.exceptions import DatabaseConnectionError
logger = logging.getLogger(__name__)
router = APIRouter()
@ -22,17 +20,9 @@ async def check_health(db: AsyncSession = Depends(get_transactional_session)):
"""
Health check endpoint. Verifies API reachability and database connection.
"""
try:
# Try executing a simple query to check DB connection
result = await db.execute(text("SELECT 1"))
if result.scalar_one() == 1:
logger.info("Health check successful: Database connection verified.")
return HealthStatus(status="ok", database="connected")
else:
# This case should ideally not happen with 'SELECT 1'
logger.error("Health check failed: Database connection check returned unexpected result.")
raise DatabaseConnectionError("Unexpected result from database connection check")
except Exception as e:
logger.error(f"Health check failed: Database connection error - {e}", exc_info=True)
raise DatabaseConnectionError(str(e))

View File

@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from app import models
from app.schemas.audit import FinancialAuditLogPublic
from app.database import get_session
from app.auth import current_active_user
from app.crud import audit as crud_audit, group as crud_group
router = APIRouter()
@router.get("/financial/group/{group_id}", response_model=List[FinancialAuditLogPublic])
async def read_financial_history_for_group(
group_id: int,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
skip: int = 0,
limit: int = 100,
):
"""
Retrieve financial audit history for a specific group.
"""
is_member = await crud_group.is_user_member(db, group_id=group_id, user_id=current_user.id)
if not is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member of this group")
history = await crud_audit.get_financial_audit_logs_for_group(
db=db, group_id=group_id, skip=skip, limit=limit
)
return history
@router.get("/financial/user/me", response_model=List[FinancialAuditLogPublic])
async def read_financial_history_for_user(
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
skip: int = 0,
limit: int = 100,
):
"""
Retrieve financial audit history for the current user.
"""
history = await crud_audit.get_financial_audit_logs_for_user(
db=db, user_id=current_user.id, skip=skip, limit=limit
)
return history

View File

@ -1,21 +1,16 @@
# app/api/v1/endpoints/invites.py
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session
from app.auth import current_active_user
from app.models import User as UserModel, UserRoleEnum
from app.models import User as UserModel
from app.schemas.invite import InviteAccept
from app.schemas.message import Message
from app.schemas.group import GroupPublic
from app.crud import invite as crud_invite
from app.crud import group as crud_group
from app.core.exceptions import (
InviteNotFoundError,
InviteExpiredError,
InviteAlreadyUsedError,
InviteCreationError,
GroupNotFoundError,
GroupMembershipError,
GroupOperationError
@ -25,7 +20,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"/accept", # Route relative to prefix "/invites"
"/accept",
response_model=GroupPublic,
summary="Accept Group Invite",
tags=["Invites"]
@ -38,41 +33,32 @@ async def accept_invite(
"""Accepts a group invite using the provided invite code."""
logger.info(f"User {current_user.email} attempting to accept invite code: {invite_in.code}")
# Get the invite - this function should only return valid, active invites
invite = await crud_invite.get_active_invite_by_code(db, code=invite_in.code)
if not invite:
logger.warning(f"Invalid or inactive invite code attempted by user {current_user.email}: {invite_in.code}")
# We can use a more generic error or a specific one. InviteNotFound is reasonable.
raise InviteNotFoundError(invite_in.code)
# Check if group still exists
group = await crud_group.get_group_by_id(db, group_id=invite.group_id)
if not group:
logger.error(f"Group {invite.group_id} not found for invite {invite_in.code}")
raise GroupNotFoundError(invite.group_id)
# Check if user is already a member
is_member = await crud_group.is_user_member(db, group_id=invite.group_id, user_id=current_user.id)
if is_member:
logger.warning(f"User {current_user.email} already a member of group {invite.group_id}")
raise GroupMembershipError(invite.group_id, "join (already a member)")
# Add user to the group
added_to_group = await crud_group.add_user_to_group(db, group_id=invite.group_id, user_id=current_user.id)
if not added_to_group:
logger.error(f"Failed to add user {current_user.email} to group {invite.group_id} during invite acceptance.")
# This could be a race condition or other issue, treat as an operational error.
raise GroupOperationError("Failed to add user to group.")
# Deactivate the invite so it cannot be used again
await crud_invite.deactivate_invite(db, invite=invite)
logger.info(f"User {current_user.email} successfully joined group {invite.group_id} via invite {invite_in.code}")
# Re-fetch the group to get the updated member list
updated_group = await crud_group.get_group_by_id(db, group_id=invite.group_id)
if not updated_group:
# This should ideally not happen as we found it before
logger.error(f"Could not re-fetch group {invite.group_id} after user {current_user.email} joined.")
raise GroupNotFoundError(invite.group_id)

View File

@ -1,4 +1,4 @@
# app/api/v1/endpoints/items.py
import logging
from typing import List as PyList, Optional
@ -6,21 +6,17 @@ from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session
from app.auth import current_active_user
# --- Import Models Correctly ---
from app.models import User as UserModel
from app.models import Item as ItemModel # <-- IMPORT Item and alias it
# --- End Import Models ---
from app.models import Item as ItemModel
from app.schemas.item import ItemCreate, ItemUpdate, ItemPublic
from app.crud import item as crud_item
from app.crud import list as crud_list
from app.core.exceptions import ItemNotFoundError, ListPermissionError, ConflictError
from app.auth import current_active_user
logger = logging.getLogger(__name__)
router = APIRouter()
# --- Helper Dependency for Item Permissions ---
# Now ItemModel is defined before being used as a type hint
async def get_item_and_verify_access(
item_id: int,
db: AsyncSession = Depends(get_transactional_session),
@ -31,19 +27,15 @@ async def get_item_and_verify_access(
if not item_db:
raise ItemNotFoundError(item_id)
# Check permission on the parent list
try:
await crud_list.check_list_permission(db=db, list_id=item_db.list_id, user_id=current_user.id)
except ListPermissionError as e:
# Re-raise with a more specific message
raise ListPermissionError(item_db.list_id, "access this item's list")
return item_db
# --- Endpoints ---
@router.post(
"/lists/{list_id}/items", # Nested under lists
"/lists/{list_id}/items",
response_model=ItemPublic,
status_code=status.HTTP_201_CREATED,
summary="Add Item to List",
@ -56,13 +48,11 @@ async def create_list_item(
current_user: UserModel = Depends(current_active_user),
):
"""Adds a new item to a specific list. User must have access to the list."""
user_email = current_user.email # Access email attribute before async operations
user_email = current_user.email
logger.info(f"User {user_email} adding item to list {list_id}: {item_in.name}")
# Verify user has access to the target list
try:
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
except ListPermissionError as e:
# Re-raise with a more specific message
raise ListPermissionError(list_id, "add items to this list")
created_item = await crud_item.create_item(
@ -73,7 +63,7 @@ async def create_list_item(
@router.get(
"/lists/{list_id}/items", # Nested under lists
"/lists/{list_id}/items",
response_model=PyList[ItemPublic],
summary="List Items in List",
tags=["Items"]
@ -82,16 +72,13 @@ async def read_list_items(
list_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
# Add sorting/filtering params later if needed: sort_by: str = 'created_at', order: str = 'asc'
):
"""Retrieves all items for a specific list if the user has access."""
user_email = current_user.email # Access email attribute before async operations
user_email = current_user.email
logger.info(f"User {user_email} listing items for list {list_id}")
# Verify user has access to the list
try:
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
except ListPermissionError as e:
# Re-raise with a more specific message
raise ListPermissionError(list_id, "view items in this list")
items = await crud_item.get_items_by_list_id(db=db, list_id=list_id)
@ -99,7 +86,7 @@ async def read_list_items(
@router.put(
"/lists/{list_id}/items/{item_id}", # Nested under lists
"/lists/{list_id}/items/{item_id}",
response_model=ItemPublic,
summary="Update Item",
tags=["Items"],
@ -111,9 +98,9 @@ async def update_item(
list_id: int,
item_id: int,
item_in: ItemUpdate,
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
item_db: ItemModel = Depends(get_item_and_verify_access),
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user), # Need user ID for completed_by
current_user: UserModel = Depends(current_active_user),
):
"""
Updates an item's details (name, quantity, is_complete, price).
@ -122,9 +109,8 @@ async def update_item(
If the version does not match, a 409 Conflict is returned.
Sets/unsets `completed_by_id` based on `is_complete` flag.
"""
user_email = current_user.email # Access email attribute before async operations
user_email = current_user.email
logger.info(f"User {user_email} attempting to update item ID: {item_id} with version {item_in.version}")
# Permission check is handled by get_item_and_verify_access dependency
try:
updated_item = await crud_item.update_item(
@ -141,7 +127,7 @@ async def update_item(
@router.delete(
"/lists/{list_id}/items/{item_id}", # Nested under lists
"/lists/{list_id}/items/{item_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete Item",
tags=["Items"],
@ -153,18 +139,16 @@ async def delete_item(
list_id: int,
item_id: int,
expected_version: Optional[int] = Query(None, description="The expected version of the item to delete for optimistic locking."),
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
item_db: ItemModel = Depends(get_item_and_verify_access),
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user), # Log who deleted it
current_user: UserModel = Depends(current_active_user),
):
"""
Deletes an item. User must have access to the list the item belongs to.
If `expected_version` is provided and does not match the item's current version,
a 409 Conflict is returned.
"""
user_email = current_user.email # Access email attribute before async operations
logger.info(f"User {user_email} attempting to delete item ID: {item_id}, expected version: {expected_version}")
# Permission check is handled by get_item_and_verify_access dependency
user_email = current_user.email
if expected_version is not None and item_db.version != expected_version:
logger.warning(

View File

@ -1,34 +1,27 @@
# app/api/v1/endpoints/lists.py
import logging
from typing import List as PyList, Optional # Alias for Python List type hint
from fastapi import APIRouter, Depends, HTTPException, status, Response, Query # Added Query
from typing import List as PyList, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session
from app.auth import current_active_user
from app.models import User as UserModel
from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail
from app.schemas.message import Message # For simple responses
from app.crud import list as crud_list
from app.crud import group as crud_group # Need for group membership check
from app.crud import group as crud_group
from app.schemas.list import ListStatus, ListStatusWithId
from app.schemas.expense import ExpensePublic # Import ExpensePublic
from app.schemas.expense import ExpensePublic
from app.core.exceptions import (
GroupMembershipError,
ListNotFoundError,
ListPermissionError,
ListStatusNotFoundError,
ConflictError, # Added ConflictError
DatabaseIntegrityError # Added DatabaseIntegrityError
ConflictError,
DatabaseIntegrityError
)
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"", # Route relative to prefix "/lists"
response_model=ListPublic, # Return basic list info on creation
"",
response_model=ListPublic,
status_code=status.HTTP_201_CREATED,
summary="Create New List",
tags=["Lists"],
@ -53,7 +46,6 @@ async def create_list(
logger.info(f"User {current_user.email} creating list: {list_in.name}")
group_id = list_in.group_id
# Permission Check: If sharing with a group, verify membership
if group_id:
is_member = await crud_group.is_user_member(db, group_id=group_id, user_id=current_user.id)
if not is_member:
@ -65,9 +57,7 @@ async def create_list(
logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.")
return created_list
except DatabaseIntegrityError as e:
# Check if this is a unique constraint violation
if "unique constraint" in str(e).lower():
# Find the existing list with the same name in the group
existing_list = await crud_list.get_list_by_name_and_group(
db=db,
name=list_in.name,
@ -81,20 +71,18 @@ async def create_list(
detail=f"A list named '{list_in.name}' already exists in this group.",
headers={"X-Existing-List": str(existing_list.id)}
)
# If it's not a unique constraint or we couldn't find the existing list, re-raise
raise
@router.get(
"", # Route relative to prefix "/lists"
response_model=PyList[ListDetail], # Return a list of detailed list info including items
"",
response_model=PyList[ListDetail],
summary="List Accessible Lists",
tags=["Lists"]
)
async def read_lists(
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
# Add pagination parameters later if needed: skip: int = 0, limit: int = 100
):
"""
Retrieves lists accessible to the current user:
@ -106,6 +94,24 @@ async def read_lists(
return lists
@router.get(
"/archived",
response_model=PyList[ListDetail],
summary="List Archived Lists",
tags=["Lists"]
)
async def read_archived_lists(
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""
Retrieves archived lists for the current user.
"""
logger.info(f"Fetching archived lists for user: {current_user.email}")
lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id, include_archived=True)
return [l for l in lists if l.archived_at]
@router.get(
"/statuses",
response_model=PyList[ListStatusWithId],
@ -128,7 +134,6 @@ async def read_lists_statuses(
statuses = await crud_list.get_lists_statuses_by_ids(db=db, list_ids=ids, user_id=current_user.id)
# The CRUD function returns a list of Row objects, so we map them to the Pydantic model
return [
ListStatusWithId(
id=s.id,
@ -141,7 +146,7 @@ async def read_lists_statuses(
@router.get(
"/{list_id}",
response_model=ListDetail, # Return detailed list info including items
response_model=ListDetail,
summary="Get List Details",
tags=["Lists"]
)
@ -155,17 +160,16 @@ async def read_list(
if the user has permission (creator or group member).
"""
logger.info(f"User {current_user.email} requesting details for list ID: {list_id}")
# The check_list_permission function will raise appropriate exceptions
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
return list_db
@router.put(
"/{list_id}",
response_model=ListPublic, # Return updated basic info
response_model=ListPublic,
summary="Update List",
tags=["Lists"],
responses={ # Add 409 to responses
responses={
status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified by someone else"}
}
)
@ -188,43 +192,40 @@ async def update_list(
updated_list = await crud_list.update_list(db=db, list_db=list_db, list_in=list_in)
logger.info(f"List {list_id} updated successfully by user {current_user.email} to version {updated_list.version}.")
return updated_list
except ConflictError as e: # Catch and re-raise as HTTPException for proper FastAPI response
except ConflictError as e:
logger.warning(f"Conflict updating list {list_id} for user {current_user.email}: {str(e)}")
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
except Exception as e: # Catch other potential errors from crud operation
except Exception as e:
logger.error(f"Error updating list {list_id} for user {current_user.email}: {str(e)}")
# Consider a more generic error, but for now, let's keep it specific if possible
# Re-raising might be better if crud layer already raises appropriate HTTPExceptions
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while updating the list.")
@router.delete(
"/{list_id}",
status_code=status.HTTP_204_NO_CONTENT, # Standard for successful DELETE with no body
summary="Delete List",
status_code=status.HTTP_204_NO_CONTENT,
summary="Archive List",
tags=["Lists"],
responses={ # Add 409 to responses
status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified, cannot delete specified version"}
responses={
status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified, cannot archive specified version"}
}
)
async def delete_list(
async def archive_list_endpoint(
list_id: int,
expected_version: Optional[int] = Query(None, description="The expected version of the list to delete for optimistic locking."),
expected_version: Optional[int] = Query(None, description="The expected version of the list to archive for optimistic locking."),
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""
Deletes a list. Requires user to be the creator of the list.
Archives a list. Requires user to be the creator of the list.
If `expected_version` is provided and does not match the list's current version,
a 409 Conflict is returned.
"""
logger.info(f"User {current_user.email} attempting to delete list ID: {list_id}, expected version: {expected_version}")
# Use the helper, requiring creator permission
logger.info(f"User {current_user.email} attempting to archive list ID: {list_id}, expected version: {expected_version}")
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id, require_creator=True)
if expected_version is not None and list_db.version != expected_version:
logger.warning(
f"Conflict deleting list {list_id} for user {current_user.email}. "
f"Conflict archiving list {list_id} for user {current_user.email}. "
f"Expected version {expected_version}, actual version {list_db.version}."
)
raise HTTPException(
@ -232,11 +233,37 @@ async def delete_list(
detail=f"List has been modified. Expected version {expected_version}, but current version is {list_db.version}. Please refresh."
)
await crud_list.delete_list(db=db, list_db=list_db)
logger.info(f"List {list_id} (version: {list_db.version}) deleted successfully by user {current_user.email}.")
await crud_list.archive_list(db=db, list_db=list_db)
logger.info(f"List {list_id} (version: {list_db.version}) archived successfully by user {current_user.email}.")
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/{list_id}/unarchive",
response_model=ListPublic,
summary="Unarchive List",
tags=["Lists"]
)
async def unarchive_list_endpoint(
list_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""
Restores an archived list.
"""
logger.info(f"User {current_user.email} attempting to unarchive list ID: {list_id}")
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id, require_creator=True)
if not list_db.archived_at:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="List is not archived.")
updated_list = await crud_list.unarchive_list(db=db, list_db=list_db)
logger.info(f"List {list_id} unarchived successfully by user {current_user.email}.")
return updated_list
@router.get(
"/{list_id}/status",
response_model=ListStatus,
@ -253,7 +280,6 @@ async def read_list_status(
if the user has permission (creator or group member).
"""
logger.info(f"User {current_user.email} requesting status for list ID: {list_id}")
# The check_list_permission is not needed here as get_list_status handles not found
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
return await crud_list.get_list_status(db=db, list_id=list_id)
@ -278,9 +304,7 @@ async def read_list_expenses(
logger.info(f"User {current_user.email} requesting expenses for list ID: {list_id}")
# Check if user has permission to access this list
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
# Get expenses for this list
expenses = await crud_expense.get_expenses_for_list(db, list_id=list_id, skip=skip, limit=limit)
return expenses

View File

@ -1,9 +1,5 @@
import logging
from typing import List
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status
from google.api_core import exceptions as google_exceptions
from fastapi import APIRouter, Depends, UploadFile, File
from app.auth import current_active_user
from app.models import User as UserModel
from app.schemas.ocr import OcrExtractResponse
@ -11,7 +7,6 @@ from app.core.gemini import GeminiOCRService, gemini_initialization_error
from app.core.exceptions import (
OCRServiceUnavailableError,
OCRServiceConfigError,
OCRUnexpectedError,
OCRQuotaExceededError,
InvalidFileTypeError,
FileTooLargeError,
@ -37,26 +32,22 @@ async def ocr_extract_items(
Accepts an image upload, sends it to Gemini Flash with a prompt
to extract shopping list items, and returns the parsed items.
"""
# Check if Gemini client initialized correctly
if gemini_initialization_error:
logger.error("OCR endpoint called but Gemini client failed to initialize.")
raise OCRServiceUnavailableError(gemini_initialization_error)
logger.info(f"User {current_user.email} uploading image '{image_file.filename}' for OCR extraction.")
# --- File Validation ---
if image_file.content_type not in settings.ALLOWED_IMAGE_TYPES:
logger.warning(f"Invalid file type uploaded by {current_user.email}: {image_file.content_type}")
raise InvalidFileTypeError()
# Simple size check
contents = await image_file.read()
if len(contents) > settings.MAX_FILE_SIZE_MB * 1024 * 1024:
logger.warning(f"File too large uploaded by {current_user.email}: {len(contents)} bytes")
raise FileTooLargeError()
try:
# Use the ocr_service instance instead of the standalone function
extracted_items = await ocr_service.extract_items(image_data=contents)
logger.info(f"Successfully extracted {len(extracted_items)} items for user {current_user.email}.")
@ -72,5 +63,4 @@ async def ocr_extract_items(
raise OCRProcessingError(str(e))
finally:
# Ensure file handle is closed
await image_file.close()

View File

@ -0,0 +1,11 @@
from fastapi import APIRouter
from app.auth import fastapi_users
from app.schemas.user import UserPublic, UserUpdate
router = APIRouter()
router.include_router(
fastapi_users.get_users_router(UserPublic, UserUpdate),
prefix="",
tags=["Users"],
)

View File

@ -21,11 +21,9 @@ from .database import get_session
from .models import User
from .config import settings
# OAuth2 configuration
config = Config('.env')
oauth = OAuth(config)
# Google OAuth2 setup
oauth.register(
name='google',
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
@ -35,7 +33,6 @@ oauth.register(
}
)
# Apple OAuth2 setup
oauth.register(
name='apple',
server_metadata_url='https://appleid.apple.com/.well-known/openid-configuration',
@ -45,13 +42,11 @@ oauth.register(
}
)
# Custom Bearer Response with Refresh Token
class BearerResponseWithRefresh(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
# Custom Bearer Transport that supports refresh tokens
class BearerTransportWithRefresh(BearerTransport):
async def get_login_response(self, token: str, refresh_token: str = None) -> Response:
if refresh_token:
@ -61,14 +56,12 @@ class BearerTransportWithRefresh(BearerTransport):
token_type="bearer"
)
else:
# Fallback to standard response if no refresh token
bearer_response = {
"access_token": token,
"token_type": "bearer"
}
return JSONResponse(bearer_response.dict() if hasattr(bearer_response, 'dict') else bearer_response)
# Custom Authentication Backend with Refresh Token Support
class AuthenticationBackendWithRefresh(AuthenticationBackend):
def __init__(
self,
@ -83,7 +76,6 @@ class AuthenticationBackendWithRefresh(AuthenticationBackend):
self.get_refresh_strategy = get_refresh_strategy
async def login(self, strategy, user) -> Response:
# Generate both access and refresh tokens
access_token = await strategy.write_token(user)
refresh_strategy = self.get_refresh_strategy()
refresh_token = await refresh_strategy.write_token(user)
@ -124,17 +116,14 @@ async def get_user_db(session: AsyncSession = Depends(get_session)):
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
yield UserManager(user_db)
# Updated transport with refresh token support
bearer_transport = BearerTransportWithRefresh(tokenUrl="auth/jwt/login")
bearer_transport = BearerTransportWithRefresh(tokenUrl="/api/v1/auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60)
def get_refresh_jwt_strategy() -> JWTStrategy:
# Refresh tokens last longer - 7 days
return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=7 * 24 * 60 * 60)
# Updated auth backend with refresh token support
auth_backend = AuthenticationBackendWithRefresh(
name="jwt",
transport=bearer_transport,

View File

@ -26,18 +26,168 @@ class Settings(BaseSettings):
MAX_FILE_SIZE_MB: int = 10 # Maximum allowed file size for OCR processing
ALLOWED_IMAGE_TYPES: list[str] = ["image/jpeg", "image/png", "image/webp"] # Supported image formats
OCR_ITEM_EXTRACTION_PROMPT: str = """
Extract the shopping list items from this image.
List each distinct item on a new line.
Ignore prices, quantities, store names, discounts, taxes, totals, and other non-item text.
Focus only on the names of the products or items to be purchased.
Add 2 underscores before and after the item name, if it is struck through.
If the image does not appear to be a shopping list or receipt, state that clearly.
Example output for a grocery list:
Milk
Eggs
Bread
__Apples__
Organic Bananas
**ROLE & GOAL**
You are an expert AI assistant specializing in Optical Character Recognition (OCR) and structured data extraction. Your primary function is to act as a "Shopping List Digitizer."
Your goal is to meticulously analyze the provided image of a shopping list, which is likely handwritten, and convert it into a structured, machine-readable JSON format. You must be accurate, infer context where necessary, and handle the inherent ambiguities of handwriting and informal list-making.
**INPUT**
You will receive a single image (`[Image]`). This image contains a shopping list. It may be:
* Neatly written or very messy.
* On lined paper, a whiteboard, a napkin, or a dedicated notepad.
* Containing doodles, stains, or other visual noise.
* Using various formats (bullet points, numbered lists, columns, simple line breaks).
* could be in English or in German.
**CORE TASK: STEP-BY-STEP ANALYSIS**
Follow these steps precisely:
1. **Initial Image Analysis & OCR:**
* Perform an advanced OCR scan on the entire image to transcribe all visible text.
* Pay close attention to the spatial layout. Identify headings, columns, and line items. Note which text elements appear to be grouped together.
2. **Item Identification & Filtering:**
* Differentiate between actual list items and non-item elements.
* **INCLUDE:** Items intended for purchase.
* **EXCLUDE:** List titles (e.g., "GROCERIES," "Target List"), dates, doodles, unrelated notes, or stray marks. Capture the list title separately if one exists.
3. **Detailed Extraction for Each Item:**
For every single item you identify, extract the following attributes. If an attribute is not present, use `null`.
* `item_name` (string): The primary name of the product.
* **Standardize:** Normalize the name. (e.g., "B. Powder" -> "Baking Powder", "A. Juice" -> "Apple Juice").
* **Contextual Guessing:** If a word is poorly written, use the context of a shopping list to make an educated guess. (e.g., "Ciffee" is almost certainly "Coffee").
* `quantity` (number or string): The amount needed.
* If a number is present (e.g., "**2** milks"), extract the number `2`.
* If it's a word (e.g., "**a dozen** eggs"), extract the string `"a dozen"`.
* If no quantity is specified (e.g., "Bread"), infer a default quantity of `1`.
* `unit` (string): The unit of measurement or packaging.
* Examples: "kg", "lbs", "liters", "gallons", "box", "can", "bag", "bunch".
* Infer where possible (e.g., for "2 Milks," the unit could be inferred as "cartons" or "gallons" depending on regional context, but it's safer to leave it `null` if not explicitly stated).
* `notes` (string): Any additional descriptive text.
* Examples: "low-sodium," "organic," "brand name (Tide)," "for the cake," "get the ripe ones."
* `category` (string): Infer a logical category for the item.
* Use common grocery store categories: `Produce`, `Dairy & Eggs`, `Meat & Seafood`, `Pantry`, `Frozen`, `Bakery`, `Beverages`, `Household`, `Personal Care`.
* If the list itself has category headings (e.g., a "DAIRY" section), use those first.
* `original_text` (string): Provide the exact, unaltered text that your OCR transcribed for this entire line item. This is crucial for verification.
* `is_crossed_out` (boolean): Set to `true` if the item is struck through, crossed out, or clearly marked as completed. Otherwise, set to `false`.
**HANDLING AMBIGUITIES AND EDGE CASES**
* **Illegible Text:** If a line or word is completely unreadable, set `item_name` to `"UNREADABLE"` and place the garbled OCR attempt in the `original_text` field.
* **Abbreviations:** Expand common shopping list abbreviations (e.g., "OJ" -> "Orange Juice", "TP" -> "Toilet Paper", "AVOs" -> "Avocados", "G. Beef" -> "Ground Beef").
* **Implicit Items:** If a line is vague like "Snacks for kids," list it as is. Do not invent specific items.
* **Multi-item Lines:** If a line contains multiple items (e.g., "Onions, Garlic, Ginger"), split them into separate item objects.
**OUTPUT FORMAT**
Your final output MUST be a single JSON object with the following structure. Do not include any explanatory text before or after the JSON block.
```json
{
"list_title": "string or null",
"items": [
{
"item_name": "string",
"quantity": "number or string",
"unit": "string or null",
"category": "string",
"notes": "string or null",
"original_text": "string",
"is_crossed_out": "boolean"
}
],
"summary": {
"total_items": "integer",
"unread_items": "integer",
"crossed_out_items": "integer"
}
}
```
**EXAMPLE WALKTHROUGH**
* **IF THE IMAGE SHOWS:** A crumpled sticky note with the title "Stuff for tonight" and the items:
* `2x Chicken Breasts`
* `~~Baguette~~` (this item is crossed out)
* `Salad mix (bag)`
* `Tomatos` (misspelled)
* `Choc Ice Cream`
* **YOUR JSON OUTPUT SHOULD BE:**
```json
{
"list_title": "Stuff for tonight",
"items": [
{
"item_name": "Chicken Breasts",
"quantity": 2,
"unit": null,
"category": "Meat & Seafood",
"notes": null,
"original_text": "2x Chicken Breasts",
"is_crossed_out": false
},
{
"item_name": "Baguette",
"quantity": 1,
"unit": null,
"category": "Bakery",
"notes": null,
"original_text": "Baguette",
"is_crossed_out": true
},
{
"item_name": "Salad Mix",
"quantity": 1,
"unit": "bag",
"category": "Produce",
"notes": null,
"original_text": "Salad mix (bag)",
"is_crossed_out": false
},
{
"item_name": "Tomatoes",
"quantity": 1,
"unit": null,
"category": "Produce",
"notes": null,
"original_text": "Tomatos",
"is_crossed_out": false
},
{
"item_name": "Chocolate Ice Cream",
"quantity": 1,
"unit": null,
"category": "Frozen",
"notes": null,
"original_text": "Choc Ice Cream",
"is_crossed_out": false
}
],
"summary": {
"total_items": 5,
"unread_items": 0,
"crossed_out_items": 1
}
}
```
**FINAL INSTRUCTION**
If the image provided is not a shopping list or is completely blank/unintelligible, respond with a JSON object where the `items` array is empty and add a note in the `list_title` field, such as "Image does not appear to be a shopping list."
Now, analyze the provided image and generate the JSON output.
"""
# --- OCR Error Messages ---
OCR_SERVICE_UNAVAILABLE: str = "OCR service is currently unavailable. Please try again later."
@ -49,7 +199,7 @@ Organic Bananas
OCR_PROCESSING_ERROR: str = "Error processing image: {detail}"
# --- Gemini AI Settings ---
GEMINI_MODEL_NAME: str = "gemini-2.0-flash" # The model to use for OCR
GEMINI_MODEL_NAME: str = "gemini-2.5-flash-preview-05-20" # The model to use for OCR
GEMINI_SAFETY_SETTINGS: dict = {
"HARM_CATEGORY_HATE_SPEECH": "BLOCK_MEDIUM_AND_ABOVE",
"HARM_CATEGORY_DANGEROUS_CONTENT": "BLOCK_MEDIUM_AND_ABOVE",

View File

@ -1,28 +1,20 @@
from typing import Dict, Any
from app.config import settings
# API Version
API_VERSION = "v1"
# API Prefix
API_PREFIX = f"/api/{API_VERSION}"
# API Endpoints
class APIEndpoints:
# Auth
AUTH = {
"LOGIN": "/auth/login",
"SIGNUP": "/auth/signup",
"REFRESH_TOKEN": "/auth/refresh-token",
}
# Users
USERS = {
"PROFILE": "/users/profile",
"UPDATE_PROFILE": "/users/profile",
}
# Lists
LISTS = {
"BASE": "/lists",
"BY_ID": "/lists/{id}",
@ -30,7 +22,6 @@ class APIEndpoints:
"ITEM": "/lists/{list_id}/items/{item_id}",
}
# Groups
GROUPS = {
"BASE": "/groups",
"BY_ID": "/groups/{id}",
@ -38,7 +29,6 @@ class APIEndpoints:
"MEMBERS": "/groups/{group_id}/members",
}
# Invites
INVITES = {
"BASE": "/invites",
"BY_ID": "/invites/{id}",
@ -46,12 +36,10 @@ class APIEndpoints:
"DECLINE": "/invites/{id}/decline",
}
# OCR
OCR = {
"PROCESS": "/ocr/process",
}
# Financials
FINANCIALS = {
"EXPENSES": "/financials/expenses",
"EXPENSE": "/financials/expenses/{id}",
@ -59,12 +47,10 @@ class APIEndpoints:
"SETTLEMENT": "/financials/settlements/{id}",
}
# Health
HEALTH = {
"CHECK": "/health",
}
# API Metadata
API_METADATA = {
"title": settings.API_TITLE,
"description": settings.API_DESCRIPTION,
@ -74,7 +60,6 @@ API_METADATA = {
"redoc_url": settings.API_REDOC_URL,
}
# API Tags
API_TAGS = [
{"name": "Authentication", "description": "Authentication and authorization endpoints"},
{"name": "Users", "description": "User management endpoints"},
@ -86,7 +71,7 @@ API_TAGS = [
{"name": "Health", "description": "Health check endpoints"},
]
# Helper function to get full API URL
def get_api_url(endpoint: str, **kwargs) -> str:
"""
Get the full API URL for an endpoint.

78
be/app/core/cache.py Normal file
View File

@ -0,0 +1,78 @@
import json
import hashlib
from functools import wraps
from typing import Any, Callable, Optional
from app.core.redis import get_redis
import pickle
def generate_cache_key(func_name: str, args: tuple, kwargs: dict) -> str:
"""Generate a unique cache key based on function name and arguments."""
# Create a string representation of args and kwargs
key_data = {
'function': func_name,
'args': str(args),
'kwargs': str(sorted(kwargs.items()))
}
key_string = json.dumps(key_data, sort_keys=True)
# Use SHA256 hash for consistent, shorter keys
return f"cache:{hashlib.sha256(key_string.encode()).hexdigest()}"
def cache(expire_time: int = 3600, key_prefix: Optional[str] = None):
"""
Decorator to cache function results in Redis.
Args:
expire_time: Expiration time in seconds (default: 1 hour)
key_prefix: Optional prefix for cache keys
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(*args, **kwargs) -> Any:
redis_client = await get_redis()
# Generate cache key
cache_key = generate_cache_key(func.__name__, args, kwargs)
if key_prefix:
cache_key = f"{key_prefix}:{cache_key}"
try:
# Try to get from cache
cached_result = await redis_client.get(cache_key)
if cached_result:
# Deserialize and return cached result
return pickle.loads(cached_result)
# Cache miss - execute function
result = await func(*args, **kwargs)
# Store result in cache
serialized_result = pickle.dumps(result)
await redis_client.setex(cache_key, expire_time, serialized_result)
return result
except Exception as e:
# If caching fails, still execute the function
print(f"Cache error: {e}")
return await func(*args, **kwargs)
return wrapper
return decorator
async def invalidate_cache_pattern(pattern: str):
"""Invalidate all cache keys matching a pattern."""
redis_client = await get_redis()
try:
keys = await redis_client.keys(pattern)
if keys:
await redis_client.delete(*keys)
except Exception as e:
print(f"Cache invalidation error: {e}")
async def clear_all_cache():
"""Clear all cache entries."""
redis_client = await get_redis()
try:
await redis_client.flushdb()
except Exception as e:
print(f"Cache clear error: {e}")

View File

@ -48,7 +48,6 @@ def calculate_next_due_date(
today = date.today()
reference_future_date = max(today, base_date)
# This loop ensures the next_due date is always in the future relative to the reference_future_date.
while next_due <= reference_future_date:
current_base_for_recalc = next_due
@ -70,9 +69,7 @@ def calculate_next_due_date(
else: # Should not be reached
break
# Safety break: if date hasn't changed, interval is zero or logic error.
if next_due == current_base_for_recalc:
# Log error ideally, then advance by one day to prevent infinite loop.
next_due += timedelta(days=1)
break

View File

@ -332,9 +332,17 @@ class UserOperationError(HTTPException):
detail=detail
)
class ChoreOperationError(HTTPException):
"""Raised when a chore-related operation fails."""
def __init__(self, detail: str):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail=detail
)
class ChoreNotFoundError(HTTPException):
"""Raised when a chore is not found."""
def __init__(self, chore_id: int, group_id: Optional[int] = None, detail: Optional[str] = None):
"""Raised when a chore or assignment is not found."""
def __init__(self, chore_id: int = None, assignment_id: int = None, group_id: Optional[int] = None, detail: Optional[str] = None):
if detail:
error_detail = detail
elif group_id is not None:
@ -354,4 +362,3 @@ class PermissionDeniedError(HTTPException):
detail=detail
)
# Financials & Cost Splitting specific errors

View File

@ -1,8 +1,6 @@
# app/core/gemini.py
import logging
from typing import List
import google.generativeai as genai
from google.generativeai.types import HarmCategory, HarmBlockThreshold # For safety settings
from google.api_core import exceptions as google_exceptions
from app.config import settings
from app.core.exceptions import (
@ -15,15 +13,12 @@ from app.core.exceptions import (
logger = logging.getLogger(__name__)
# --- Global variable to hold the initialized model client ---
gemini_flash_client = None
gemini_initialization_error = None # Store potential init error
gemini_initialization_error = None
# --- Configure and Initialize ---
try:
if settings.GEMINI_API_KEY:
genai.configure(api_key=settings.GEMINI_API_KEY)
# Initialize the specific model we want to use
gemini_flash_client = genai.GenerativeModel(
model_name=settings.GEMINI_MODEL_NAME,
generation_config=genai.types.GenerationConfig(
@ -32,18 +27,15 @@ try:
)
logger.info(f"Gemini AI client initialized successfully for model '{settings.GEMINI_MODEL_NAME}'.")
else:
# Store error if API key is missing
gemini_initialization_error = "GEMINI_API_KEY not configured. Gemini client not initialized."
logger.error(gemini_initialization_error)
except Exception as e:
# Catch any other unexpected errors during initialization
gemini_initialization_error = f"Failed to initialize Gemini AI client: {e}"
logger.exception(gemini_initialization_error) # Log full traceback
gemini_flash_client = None # Ensure client is None on error
logger.exception(gemini_initialization_error)
gemini_flash_client = None
# --- Function to get the client (optional, allows checking error) ---
def get_gemini_client():
"""
Returns the initialized Gemini client instance.
@ -52,23 +44,172 @@ def get_gemini_client():
if gemini_initialization_error:
raise OCRServiceConfigError()
if gemini_flash_client is None:
# This case should ideally be covered by the check above, but as a safeguard:
raise OCRServiceConfigError()
return gemini_flash_client
# Define the prompt as a constant
OCR_ITEM_EXTRACTION_PROMPT = """
Extract the shopping list items from this image.
List each distinct item on a new line.
Ignore prices, quantities, store names, discounts, taxes, totals, and other non-item text.
Focus only on the names of the products or items to be purchased.
If the image does not appear to be a shopping list or receipt, state that clearly.
Example output for a grocery list:
Milk
Eggs
Bread
Apples
Organic Bananas
**ROLE & GOAL**
You are an expert AI assistant specializing in Optical Character Recognition (OCR) and structured data extraction. Your primary function is to act as a "Shopping List Digitizer."
Your goal is to meticulously analyze the provided image of a shopping list, which is likely handwritten, and convert it into a structured, machine-readable JSON format. You must be accurate, infer context where necessary, and handle the inherent ambiguities of handwriting and informal list-making.
**INPUT**
You will receive a single image (`[Image]`). This image contains a shopping list. It may be:
* Neatly written or very messy.
* On lined paper, a whiteboard, a napkin, or a dedicated notepad.
* Containing doodles, stains, or other visual noise.
* Using various formats (bullet points, numbered lists, columns, simple line breaks).
* could be in English or in German.
**CORE TASK: STEP-BY-STEP ANALYSIS**
Follow these steps precisely:
1. **Initial Image Analysis & OCR:**
* Perform an advanced OCR scan on the entire image to transcribe all visible text.
* Pay close attention to the spatial layout. Identify headings, columns, and line items. Note which text elements appear to be grouped together.
2. **Item Identification & Filtering:**
* Differentiate between actual list items and non-item elements.
* **INCLUDE:** Items intended for purchase.
* **EXCLUDE:** List titles (e.g., "GROCERIES," "Target List"), dates, doodles, unrelated notes, or stray marks. Capture the list title separately if one exists.
3. **Detailed Extraction for Each Item:**
For every single item you identify, extract the following attributes. If an attribute is not present, use `null`.
* `item_name` (string): The primary name of the product.
* **Standardize:** Normalize the name. (e.g., "B. Powder" -> "Baking Powder", "A. Juice" -> "Apple Juice").
* **Contextual Guessing:** If a word is poorly written, use the context of a shopping list to make an educated guess. (e.g., "Ciffee" is almost certainly "Coffee").
* `quantity` (number or string): The amount needed.
* If a number is present (e.g., "**2** milks"), extract the number `2`.
* If it's a word (e.g., "**a dozen** eggs"), extract the string `"a dozen"`.
* If no quantity is specified (e.g., "Bread"), infer a default quantity of `1`.
* `unit` (string): The unit of measurement or packaging.
* Examples: "kg", "lbs", "liters", "gallons", "box", "can", "bag", "bunch".
* Infer where possible (e.g., for "2 Milks," the unit could be inferred as "cartons" or "gallons" depending on regional context, but it's safer to leave it `null` if not explicitly stated).
* `notes` (string): Any additional descriptive text.
* Examples: "low-sodium," "organic," "brand name (Tide)," "for the cake," "get the ripe ones."
* `category` (string): Infer a logical category for the item.
* Use common grocery store categories: `Produce`, `Dairy & Eggs`, `Meat & Seafood`, `Pantry`, `Frozen`, `Bakery`, `Beverages`, `Household`, `Personal Care`.
* If the list itself has category headings (e.g., a "DAIRY" section), use those first.
* `original_text` (string): Provide the exact, unaltered text that your OCR transcribed for this entire line item. This is crucial for verification.
* `is_crossed_out` (boolean): Set to `true` if the item is struck through, crossed out, or clearly marked as completed. Otherwise, set to `false`.
**HANDLING AMBIGUITIES AND EDGE CASES**
* **Illegible Text:** If a line or word is completely unreadable, set `item_name` to `"UNREADABLE"` and place the garbled OCR attempt in the `original_text` field.
* **Abbreviations:** Expand common shopping list abbreviations (e.g., "OJ" -> "Orange Juice", "TP" -> "Toilet Paper", "AVOs" -> "Avocados", "G. Beef" -> "Ground Beef").
* **Implicit Items:** If a line is vague like "Snacks for kids," list it as is. Do not invent specific items.
* **Multi-item Lines:** If a line contains multiple items (e.g., "Onions, Garlic, Ginger"), split them into separate item objects.
**OUTPUT FORMAT**
Your final output MUST be a single JSON object with the following structure. Do not include any explanatory text before or after the JSON block.
```json
{
"list_title": "string or null",
"items": [
{
"item_name": "string",
"quantity": "number or string",
"unit": "string or null",
"category": "string",
"notes": "string or null",
"original_text": "string",
"is_crossed_out": "boolean"
}
],
"summary": {
"total_items": "integer",
"unread_items": "integer",
"crossed_out_items": "integer"
}
}
```
**EXAMPLE WALKTHROUGH**
* **IF THE IMAGE SHOWS:** A crumpled sticky note with the title "Stuff for tonight" and the items:
* `2x Chicken Breasts`
* `~~Baguette~~` (this item is crossed out)
* `Salad mix (bag)`
* `Tomatos` (misspelled)
* `Choc Ice Cream`
* **YOUR JSON OUTPUT SHOULD BE:**
```json
{
"list_title": "Stuff for tonight",
"items": [
{
"item_name": "Chicken Breasts",
"quantity": 2,
"unit": null,
"category": "Meat & Seafood",
"notes": null,
"original_text": "2x Chicken Breasts",
"is_crossed_out": false
},
{
"item_name": "Baguette",
"quantity": 1,
"unit": null,
"category": "Bakery",
"notes": null,
"original_text": "Baguette",
"is_crossed_out": true
},
{
"item_name": "Salad Mix",
"quantity": 1,
"unit": "bag",
"category": "Produce",
"notes": null,
"original_text": "Salad mix (bag)",
"is_crossed_out": false
},
{
"item_name": "Tomatoes",
"quantity": 1,
"unit": null,
"category": "Produce",
"notes": null,
"original_text": "Tomatos",
"is_crossed_out": false
},
{
"item_name": "Chocolate Ice Cream",
"quantity": 1,
"unit": null,
"category": "Frozen",
"notes": null,
"original_text": "Choc Ice Cream",
"is_crossed_out": false
}
],
"summary": {
"total_items": 5,
"unread_items": 0,
"crossed_out_items": 1
}
}
```
**FINAL INSTRUCTION**
If the image provided is not a shopping list or is completely blank/unintelligible, respond with a JSON object where the `items` array is empty and add a note in the `list_title` field, such as "Image does not appear to be a shopping list."
Now, analyze the provided image and generate the JSON output.
"""
async def extract_items_from_image_gemini(image_bytes: bytes, mime_type: str = "image/jpeg") -> List[str]:
@ -92,29 +233,22 @@ async def extract_items_from_image_gemini(image_bytes: bytes, mime_type: str = "
try:
client = get_gemini_client() # Raises OCRServiceConfigError if not initialized
# Prepare image part for multimodal input
image_part = {
"mime_type": mime_type,
"data": image_bytes
}
# Prepare the full prompt content
prompt_parts = [
settings.OCR_ITEM_EXTRACTION_PROMPT, # Text prompt first
image_part # Then the image
settings.OCR_ITEM_EXTRACTION_PROMPT,
image_part
]
logger.info("Sending image to Gemini for item extraction...")
# Make the API call
# Use generate_content_async for async FastAPI
response = await client.generate_content_async(prompt_parts)
# --- Process the response ---
# Check for safety blocks or lack of content
if not response.candidates or not response.candidates[0].content.parts:
logger.warning("Gemini response blocked or empty.", extra={"response": response})
# Check finish_reason if available
finish_reason = response.candidates[0].finish_reason if response.candidates else 'UNKNOWN'
safety_ratings = response.candidates[0].safety_ratings if response.candidates else 'N/A'
if finish_reason == 'SAFETY':
@ -122,18 +256,13 @@ async def extract_items_from_image_gemini(image_bytes: bytes, mime_type: str = "
else:
raise OCRUnexpectedError()
# Extract text - assumes the first part of the first candidate is the text response
raw_text = response.text # response.text is a shortcut for response.candidates[0].content.parts[0].text
raw_text = response.text
logger.info("Received raw text from Gemini.")
# logger.debug(f"Gemini Raw Text:\n{raw_text}") # Optional: Log full response text
# Parse the text response
items = []
for line in raw_text.splitlines(): # Split by newline
cleaned_line = line.strip() # Remove leading/trailing whitespace
# Basic filtering: ignore empty lines and potential non-item lines
if cleaned_line and len(cleaned_line) > 1: # Ignore very short lines too?
# Add more sophisticated filtering if needed (e.g., regex, keyword check)
for line in raw_text.splitlines():
cleaned_line = line.strip()
if cleaned_line and len(cleaned_line) > 1:
items.append(cleaned_line)
logger.info(f"Extracted {len(items)} potential items.")
@ -145,12 +274,9 @@ async def extract_items_from_image_gemini(image_bytes: bytes, mime_type: str = "
raise OCRQuotaExceededError()
raise OCRServiceUnavailableError()
except (OCRServiceConfigError, OCRQuotaExceededError, OCRServiceUnavailableError, OCRProcessingError, OCRUnexpectedError):
# Re-raise specific OCR exceptions
raise
except Exception as e:
# Catch other unexpected errors during generation or processing
logger.error(f"Unexpected error during Gemini item extraction: {e}", exc_info=True)
# Wrap in a custom exception
raise OCRUnexpectedError()
class GeminiOCRService:
@ -186,27 +312,22 @@ class GeminiOCRService:
OCRUnexpectedError: For any other unexpected errors.
"""
try:
# Create image part
image_parts = [{"mime_type": mime_type, "data": image_data}]
# Generate content
response = await self.model.generate_content_async(
contents=[settings.OCR_ITEM_EXTRACTION_PROMPT, *image_parts]
)
# Process response
if not response.text:
logger.warning("Gemini response is empty")
raise OCRUnexpectedError()
# Check for safety blocks
if hasattr(response, 'candidates') and response.candidates and hasattr(response.candidates[0], 'finish_reason'):
finish_reason = response.candidates[0].finish_reason
if finish_reason == 'SAFETY':
safety_ratings = response.candidates[0].safety_ratings if hasattr(response.candidates[0], 'safety_ratings') else 'N/A'
raise OCRProcessingError(f"Gemini response blocked due to safety settings. Ratings: {safety_ratings}")
# Split response into lines and clean up
items = []
for line in response.text.splitlines():
cleaned_line = line.strip()
@ -222,7 +343,6 @@ class GeminiOCRService:
raise OCRQuotaExceededError()
raise OCRServiceUnavailableError()
except (OCRServiceConfigError, OCRQuotaExceededError, OCRServiceUnavailableError, OCRProcessingError, OCRUnexpectedError):
# Re-raise specific OCR exceptions
raise
except Exception as e:
logger.error(f"Unexpected error during Gemini item extraction: {e}", exc_info=True)

7
be/app/core/redis.py Normal file
View File

@ -0,0 +1,7 @@
import redis.asyncio as redis
from app.config import settings
redis_pool = redis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
async def get_redis():
return redis_pool

View File

@ -2,7 +2,6 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from app.config import settings
from app.jobs.recurring_expenses import generate_recurring_expenses
from app.db.session import async_session
@ -10,11 +9,8 @@ import logging
logger = logging.getLogger(__name__)
# Convert async database URL to sync URL for APScheduler
# Replace postgresql+asyncpg:// with postgresql://
sync_db_url = settings.DATABASE_URL.replace('postgresql+asyncpg://', 'postgresql://')
# Configure the scheduler
jobstores = {
'default': SQLAlchemyJobStore(url=sync_db_url)
}
@ -36,7 +32,10 @@ scheduler = AsyncIOScheduler(
)
async def run_recurring_expenses_job():
"""Wrapper function to run the recurring expenses job with a database session."""
"""Wrapper function to run the recurring expenses job with a database session.
This function is used to generate recurring expenses for the user.
"""
try:
async with async_session() as session:
await generate_recurring_expenses(session)
@ -47,7 +46,6 @@ async def run_recurring_expenses_job():
def init_scheduler():
"""Initialize and start the scheduler."""
try:
# Add the recurring expenses job
scheduler.add_job(
run_recurring_expenses_job,
trigger=CronTrigger(hour=0, minute=0), # Run at midnight UTC
@ -56,7 +54,6 @@ def init_scheduler():
replace_existing=True
)
# Start the scheduler
scheduler.start()
logger.info("Scheduler started successfully")
except Exception as e:

View File

@ -1,20 +1,8 @@
# app/core/security.py
from datetime import datetime, timedelta, timezone
from typing import Any, Union, Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from datetime import datetime, timedelta
from jose import jwt
from typing import Optional
from app.config import settings # Import settings from config
# --- Password Hashing ---
# These functions are used for password hashing and verification
# They complement FastAPI-Users but provide direct access to the underlying password functionality
# when needed outside of the FastAPI-Users authentication flow.
# Configure passlib context
# Using bcrypt as the default hashing scheme
# 'deprecated="auto"' will automatically upgrade hashes if needed on verification
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
@ -33,7 +21,6 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
try:
return pwd_context.verify(plain_password, hashed_password)
except Exception:
# Handle potential errors during verification (e.g., invalid hash format)
return False
def hash_password(password: str) -> str:
@ -50,24 +37,38 @@ def hash_password(password: str) -> str:
"""
return pwd_context.hash(password)
# Alias for compatibility with guest.py
def get_password_hash(password: str) -> str:
"""
Alias for hash_password function for backward compatibility.
# --- JSON Web Tokens (JWT) ---
# FastAPI-Users now handles all JWT token creation and validation.
# The code below is commented out because FastAPI-Users provides these features.
# It's kept for reference in case a custom implementation is needed later.
Args:
password: The plain text password to hash.
# Example of a potential future implementation:
# def get_subject_from_token(token: str) -> Optional[str]:
# """
# Extract the subject (user ID) from a JWT token.
# This would be used if we need to validate tokens outside of FastAPI-Users flow.
# For now, use fastapi_users.current_user dependency instead.
# """
# # This would need to use FastAPI-Users' token verification if ever implemented
# # For example, by decoding the token using the strategy from the auth backend
# try:
# payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
# return payload.get("sub")
# except JWTError:
# return None
# return None
Returns:
The resulting hash string.
"""
return hash_password(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token.
Args:
data: The data to encode in the token (typically {"sub": email}).
expires_delta: Optional custom expiration time.
Returns:
The encoded JWT token.
"""
from app.config import settings
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
return encoded_jwt

77
be/app/crud/audit.py Normal file
View File

@ -0,0 +1,77 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy import union_all, or_
from typing import List, Optional
from app.models import FinancialAuditLog, Base, User, Group, Expense, Settlement
from app.schemas.audit import FinancialAuditLogCreate
async def create_financial_audit_log(
db: AsyncSession,
*,
user_id: int | None,
action_type: str,
entity: Base,
details: dict | None = None
) -> FinancialAuditLog:
log_entry_data = FinancialAuditLogCreate(
user_id=user_id,
action_type=action_type,
entity_type=entity.__class__.__name__,
entity_id=entity.id,
details=details
)
log_entry = FinancialAuditLog(**log_entry_data.dict())
db.add(log_entry)
await db.commit()
await db.refresh(log_entry)
return log_entry
async def get_financial_audit_logs_for_group(db: AsyncSession, *, group_id: int, skip: int = 0, limit: int = 100) -> List[FinancialAuditLog]:
"""
Get financial audit logs for all entities that belong to a specific group.
This includes Expenses and Settlements that are linked to the group.
"""
# Get all expense IDs for this group
expense_ids_query = select(Expense.id).where(Expense.group_id == group_id)
expense_result = await db.execute(expense_ids_query)
expense_ids = [row[0] for row in expense_result.fetchall()]
# Get all settlement IDs for this group
settlement_ids_query = select(Settlement.id).where(Settlement.group_id == group_id)
settlement_result = await db.execute(settlement_ids_query)
settlement_ids = [row[0] for row in settlement_result.fetchall()]
# Build conditions for the audit log query
conditions = []
if expense_ids:
conditions.append(
(FinancialAuditLog.entity_type == 'Expense') &
(FinancialAuditLog.entity_id.in_(expense_ids))
)
if settlement_ids:
conditions.append(
(FinancialAuditLog.entity_type == 'Settlement') &
(FinancialAuditLog.entity_id.in_(settlement_ids))
)
# If no entities exist for this group, return empty list
if not conditions:
return []
# Query audit logs for all relevant entities
query = select(FinancialAuditLog).where(
or_(*conditions)
).order_by(FinancialAuditLog.timestamp.desc()).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
async def get_financial_audit_logs_for_user(db: AsyncSession, *, user_id: int, skip: int = 0, limit: int = 100) -> List[FinancialAuditLog]:
result = await db.execute(
select(FinancialAuditLog)
.where(FinancialAuditLog.user_id == user_id)
.order_by(FinancialAuditLog.timestamp.desc())
.offset(skip).limit(limit)
)
return result.scalars().all()

38
be/app/crud/category.py Normal file
View File

@ -0,0 +1,38 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from typing import List, Optional
from app.models import Category
from app.schemas.category import CategoryCreate, CategoryUpdate
async def create_category(db: AsyncSession, category_in: CategoryCreate, user_id: int, group_id: Optional[int] = None) -> Category:
db_category = Category(**category_in.dict(), user_id=user_id, group_id=group_id)
db.add(db_category)
await db.commit()
await db.refresh(db_category)
return db_category
async def get_user_categories(db: AsyncSession, user_id: int) -> List[Category]:
result = await db.execute(select(Category).where(Category.user_id == user_id))
return result.scalars().all()
async def get_group_categories(db: AsyncSession, group_id: int) -> List[Category]:
result = await db.execute(select(Category).where(Category.group_id == group_id))
return result.scalars().all()
async def get_category(db: AsyncSession, category_id: int) -> Optional[Category]:
return await db.get(Category, category_id)
async def update_category(db: AsyncSession, db_category: Category, category_in: CategoryUpdate) -> Category:
update_data = category_in.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_category, key, value)
db.add(db_category)
await db.commit()
await db.refresh(db_category)
return db_category
async def delete_category(db: AsyncSession, db_category: Category):
await db.delete(db_category)
await db.commit()
return db_category

View File

@ -6,10 +6,11 @@ from typing import List, Optional
import logging
from datetime import date, datetime
from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup
from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup, ChoreHistoryEventTypeEnum
from app.schemas.chore import ChoreCreate, ChoreUpdate, ChoreAssignmentCreate, ChoreAssignmentUpdate
from app.core.chore_utils import calculate_next_due_date
from app.crud.group import get_group_by_id, is_user_member
from app.crud.history import create_chore_history_entry, create_assignment_history_entry
from app.core.exceptions import ChoreNotFoundError, GroupNotFoundError, PermissionDeniedError, DatabaseIntegrityError
logger = logging.getLogger(__name__)
@ -17,7 +18,6 @@ logger = logging.getLogger(__name__)
async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
"""Gets all chores (personal and group) for a user in optimized queries."""
# Get personal chores query
personal_chores_query = (
select(Chore)
.where(
@ -26,7 +26,6 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
)
)
# Get user's group IDs first
user_groups_result = await db.execute(
select(UserGroup.group_id).where(UserGroup.user_id == user_id)
)
@ -34,18 +33,19 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
all_chores = []
# Execute personal chores query
personal_result = await db.execute(
personal_chores_query
.options(
selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
.order_by(Chore.next_due_date, Chore.name)
)
all_chores.extend(personal_result.scalars().all())
# If user has groups, get all group chores in one query
if user_group_ids:
group_chores_result = await db.execute(
select(Chore)
@ -56,7 +56,10 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
.options(
selectinload(Chore.creator),
selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
.order_by(Chore.next_due_date, Chore.name)
)
@ -71,12 +74,10 @@ async def create_chore(
group_id: Optional[int] = None
) -> Chore:
"""Creates a new chore, either personal or within a specific group."""
# Use the transaction pattern from the FastAPI strategy
async with db.begin_nested() if db.in_transaction() else db.begin():
if chore_in.type == ChoreTypeEnum.group:
if not group_id:
raise ValueError("group_id is required for group chores")
# Validate group existence and user membership
group = await get_group_by_id(db, group_id)
if not group:
raise GroupNotFoundError(group_id)
@ -86,28 +87,44 @@ async def create_chore(
if group_id:
raise ValueError("group_id must be None for personal chores")
chore_data = chore_in.model_dump(exclude_unset=True, exclude={'group_id'})
if 'parent_chore_id' in chore_data and chore_data['parent_chore_id']:
parent_chore = await get_chore_by_id(db, chore_data['parent_chore_id'])
if not parent_chore:
raise ChoreNotFoundError(chore_data['parent_chore_id'])
db_chore = Chore(
**chore_in.model_dump(exclude_unset=True, exclude={'group_id'}),
**chore_data,
group_id=group_id,
created_by_id=user_id,
)
# Specific check for custom frequency
if chore_in.frequency == ChoreFrequencyEnum.custom and chore_in.custom_interval_days is None:
raise ValueError("custom_interval_days must be set for custom frequency chores.")
db.add(db_chore)
await db.flush() # Get the ID for the chore
await db.flush()
await create_chore_history_entry(
db,
chore_id=db_chore.id,
group_id=db_chore.group_id,
changed_by_user_id=user_id,
event_type=ChoreHistoryEventTypeEnum.CREATED,
event_data={"chore_name": db_chore.name}
)
try:
# Load relationships for the response with eager loading
result = await db.execute(
select(Chore)
.where(Chore.id == db_chore.id)
.options(
selectinload(Chore.creator),
selectinload(Chore.group),
selectinload(Chore.assignments)
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
)
return result.scalar_one()
@ -120,7 +137,14 @@ async def get_chore_by_id(db: AsyncSession, chore_id: int) -> Optional[Chore]:
result = await db.execute(
select(Chore)
.where(Chore.id == chore_id)
.options(selectinload(Chore.creator), selectinload(Chore.group), selectinload(Chore.assignments))
.options(
selectinload(Chore.creator),
selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
)
return result.scalar_one_or_none()
@ -152,7 +176,10 @@ async def get_personal_chores(
)
.options(
selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
.order_by(Chore.next_due_date, Chore.name)
)
@ -175,7 +202,10 @@ async def get_chores_by_group_id(
)
.options(
selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
.order_by(Chore.next_due_date, Chore.name)
)
@ -194,7 +224,8 @@ async def update_chore(
if not db_chore:
raise ChoreNotFoundError(chore_id, group_id)
# Check permissions
original_data = {field: getattr(db_chore, field) for field in chore_in.model_dump(exclude_unset=True)}
if db_chore.type == ChoreTypeEnum.group:
if not group_id:
raise ValueError("group_id is required for group chores")
@ -202,7 +233,7 @@ async def update_chore(
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {group_id}")
if db_chore.group_id != group_id:
raise ChoreNotFoundError(chore_id, group_id)
else: # personal chore
else:
if group_id:
raise ValueError("group_id must be None for personal chores")
if db_chore.created_by_id != user_id:
@ -210,7 +241,14 @@ async def update_chore(
update_data = chore_in.model_dump(exclude_unset=True)
# Handle type change
if 'parent_chore_id' in update_data:
if update_data['parent_chore_id']:
parent_chore = await get_chore_by_id(db, update_data['parent_chore_id'])
if not parent_chore:
raise ChoreNotFoundError(update_data['parent_chore_id'])
# Setting parent_chore_id to None is allowed
setattr(db_chore, 'parent_chore_id', update_data['parent_chore_id'])
if 'type' in update_data:
new_type = update_data['type']
if new_type == ChoreTypeEnum.group and not group_id:
@ -245,15 +283,34 @@ async def update_chore(
if db_chore.frequency == ChoreFrequencyEnum.custom and db_chore.custom_interval_days is None:
raise ValueError("custom_interval_days must be set for custom frequency chores.")
changes = {}
for field, old_value in original_data.items():
new_value = getattr(db_chore, field)
if old_value != new_value:
changes[field] = {"old": str(old_value), "new": str(new_value)}
if changes:
await create_chore_history_entry(
db,
chore_id=chore_id,
group_id=db_chore.group_id,
changed_by_user_id=user_id,
event_type=ChoreHistoryEventTypeEnum.UPDATED,
event_data=changes
)
try:
await db.flush() # Flush changes within the transaction
await db.flush()
result = await db.execute(
select(Chore)
.where(Chore.id == db_chore.id)
.options(
selectinload(Chore.creator),
selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
)
return result.scalar_one()
@ -273,7 +330,15 @@ async def delete_chore(
if not db_chore:
raise ChoreNotFoundError(chore_id, group_id)
# Check permissions
await create_chore_history_entry(
db,
chore_id=chore_id,
group_id=db_chore.group_id,
changed_by_user_id=user_id,
event_type=ChoreHistoryEventTypeEnum.DELETED,
event_data={"chore_name": db_chore.name}
)
if db_chore.type == ChoreTypeEnum.group:
if not group_id:
raise ValueError("group_id is required for group chores")
@ -289,7 +354,7 @@ async def delete_chore(
try:
await db.delete(db_chore)
await db.flush() # Ensure deletion is processed within the transaction
await db.flush()
return True
except Exception as e:
logger.error(f"Error deleting chore {chore_id}: {e}", exc_info=True)
@ -304,34 +369,40 @@ async def create_chore_assignment(
) -> ChoreAssignment:
"""Creates a new chore assignment. User must be able to manage the chore."""
async with db.begin_nested() if db.in_transaction() else db.begin():
# Get the chore and validate permissions
chore = await get_chore_by_id(db, assignment_in.chore_id)
if not chore:
raise ChoreNotFoundError(chore_id=assignment_in.chore_id)
# Check permissions to assign this chore
if chore.type == ChoreTypeEnum.personal:
if chore.created_by_id != user_id:
raise PermissionDeniedError(detail="Only the creator can assign personal chores")
else: # group chore
if not await is_user_member(db, chore.group_id, user_id):
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore.group_id}")
# For group chores, check if assignee is also a group member
if not await is_user_member(db, chore.group_id, assignment_in.assigned_to_user_id):
raise PermissionDeniedError(detail=f"Cannot assign chore to user {assignment_in.assigned_to_user_id} who is not a group member")
db_assignment = ChoreAssignment(**assignment_in.model_dump(exclude_unset=True))
db.add(db_assignment)
await db.flush() # Get the ID for the assignment
await db.flush()
await create_assignment_history_entry(
db,
assignment_id=db_assignment.id,
changed_by_user_id=user_id,
event_type=ChoreHistoryEventTypeEnum.ASSIGNED,
event_data={"assigned_to_user_id": db_assignment.assigned_to_user_id, "due_date": db_assignment.due_date.isoformat()}
)
try:
# Load relationships for the response
result = await db.execute(
select(ChoreAssignment)
.where(ChoreAssignment.id == db_assignment.id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user)
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
)
return result.scalar_one()
@ -346,7 +417,9 @@ async def get_chore_assignment_by_id(db: AsyncSession, assignment_id: int) -> Op
.where(ChoreAssignment.id == assignment_id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user)
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
)
return result.scalar_one_or_none()
@ -364,7 +437,9 @@ async def get_user_assignments(
query = query.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user)
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
result = await db.execute(query)
@ -380,11 +455,10 @@ async def get_chore_assignments(
if not chore:
raise ChoreNotFoundError(chore_id=chore_id)
# Check permissions
if chore.type == ChoreTypeEnum.personal:
if chore.created_by_id != user_id:
raise PermissionDeniedError(detail="Can only view assignments for own personal chores")
else: # group chore
else:
if not await is_user_member(db, chore.group_id, user_id):
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore.group_id}")
@ -393,7 +467,9 @@ async def get_chore_assignments(
.where(ChoreAssignment.chore_id == chore_id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user)
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
.order_by(ChoreAssignment.due_date, ChoreAssignment.id)
)
@ -405,72 +481,72 @@ async def update_chore_assignment(
assignment_in: ChoreAssignmentUpdate,
user_id: int
) -> Optional[ChoreAssignment]:
"""Updates a chore assignment. Only the assignee can mark it complete."""
"""Updates a chore assignment, e.g., to mark it as complete."""
async with db.begin_nested() if db.in_transaction() else db.begin():
db_assignment = await get_chore_assignment_by_id(db, assignment_id)
if not db_assignment:
raise ChoreNotFoundError(assignment_id=assignment_id)
return None
# Load the chore for permission checking
chore = await get_chore_by_id(db, db_assignment.chore_id)
if not chore:
raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
# Permission Check: only assigned user or group owner can update
is_allowed = db_assignment.assigned_to_user_id == user_id
if not is_allowed and db_assignment.chore.group_id:
user_role = await get_user_role_in_group(db, db_assignment.chore.group_id, user_id)
is_allowed = user_role == UserRoleEnum.owner
# Check permissions - only assignee can complete, but chore managers can reschedule
can_manage = False
if chore.type == ChoreTypeEnum.personal:
can_manage = chore.created_by_id == user_id
else: # group chore
can_manage = await is_user_member(db, chore.group_id, user_id)
can_complete = db_assignment.assigned_to_user_id == user_id
if not is_allowed:
raise PermissionDeniedError("You cannot update this chore assignment.")
original_status = db_assignment.is_complete
update_data = assignment_in.model_dump(exclude_unset=True)
# Check specific permissions for different updates
if 'is_complete' in update_data and not can_complete:
raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete")
if 'due_date' in update_data and not can_manage:
raise PermissionDeniedError(detail="Only chore managers can reschedule assignments")
# Handle completion logic
if 'is_complete' in update_data and update_data['is_complete']:
if not db_assignment.is_complete: # Only if not already complete
update_data['completed_at'] = datetime.utcnow()
# Update parent chore's last_completed_at and recalculate next_due_date
chore.last_completed_at = update_data['completed_at']
chore.next_due_date = calculate_next_due_date(
current_due_date=chore.next_due_date,
frequency=chore.frequency,
custom_interval_days=chore.custom_interval_days,
last_completed_date=chore.last_completed_at
)
elif 'is_complete' in update_data and not update_data['is_complete']:
# If marking as incomplete, clear completed_at
update_data['completed_at'] = None
# Apply updates
for field, value in update_data.items():
setattr(db_assignment, field, value)
try:
await db.flush() # Flush changes within the transaction
if 'is_complete' in update_data:
new_status = update_data['is_complete']
history_event = None
if new_status and not original_status:
db_assignment.completed_at = datetime.utcnow()
history_event = ChoreHistoryEventTypeEnum.COMPLETED
# Load relationships for the response
# Advance the next_due_date of the parent chore
if db_assignment.chore:
db_assignment.chore.last_completed_at = db_assignment.completed_at
db_assignment.chore.next_due_date = calculate_next_due_date(
db_assignment.chore.frequency,
db_assignment.chore.last_completed_at.date() if db_assignment.chore.last_completed_at else date.today(),
db_assignment.chore.custom_interval_days
)
elif not new_status and original_status:
db_assignment.completed_at = None
history_event = ChoreHistoryEventTypeEnum.REOPENED
# Policy: Do not automatically roll back parent chore's due date.
if history_event:
await create_assignment_history_entry(
db=db,
assignment_id=assignment_id,
changed_by_user_id=user_id,
event_type=history_event,
event_data={"new_status": new_status}
)
await db.flush()
try:
result = await db.execute(
select(ChoreAssignment)
.where(ChoreAssignment.id == db_assignment.id)
.where(ChoreAssignment.id == assignment_id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user)
)
)
return result.scalar_one()
except Exception as e:
logger.error(f"Error updating chore assignment {assignment_id}: {e}", exc_info=True)
raise DatabaseIntegrityError(f"Could not update chore assignment {assignment_id}. Error: {str(e)}")
logger.error(f"Error updating assignment: {e}", exc_info=True)
await db.rollback()
raise DatabaseIntegrityError(f"Could not update assignment. Error: {str(e)}")
async def delete_chore_assignment(
db: AsyncSession,
@ -483,22 +559,28 @@ async def delete_chore_assignment(
if not db_assignment:
raise ChoreNotFoundError(assignment_id=assignment_id)
# Load the chore for permission checking
await create_assignment_history_entry(
db,
assignment_id=assignment_id,
changed_by_user_id=user_id,
event_type=ChoreHistoryEventTypeEnum.UNASSIGNED,
event_data={"unassigned_user_id": db_assignment.assigned_to_user_id}
)
chore = await get_chore_by_id(db, db_assignment.chore_id)
if not chore:
raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
# Check permissions
if chore.type == ChoreTypeEnum.personal:
if chore.created_by_id != user_id:
raise PermissionDeniedError(detail="Only the creator can delete personal chore assignments")
else: # group chore
else:
if not await is_user_member(db, chore.group_id, user_id):
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore.group_id}")
try:
await db.delete(db_assignment)
await db.flush() # Ensure deletion is processed within the transaction
await db.flush()
return True
except Exception as e:
logger.error(f"Error deleting chore assignment {assignment_id}: {e}", exc_info=True)

View File

@ -7,6 +7,7 @@ from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError # A
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation as DecimalInvalidOperation
from typing import Callable, List as PyList, Optional, Sequence, Dict, defaultdict, Any
from datetime import datetime, timezone # Added timezone
import json
from app.models import (
Expense as ExpenseModel,
@ -34,6 +35,7 @@ from app.core.exceptions import (
ExpenseOperationError # Added specific exception
)
from app.models import RecurrencePattern
from app.crud.audit import create_financial_audit_log
# Placeholder for InvalidOperationError if not defined in app.core.exceptions
# This should be a proper HTTPException subclass if used in API layer
@ -215,6 +217,13 @@ async def create_expense(db: AsyncSession, expense_in: ExpenseCreate, current_us
# await transaction.rollback() # Should be handled by context manager
raise ExpenseOperationError("Failed to load expense after creation.")
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="EXPENSE_CREATED",
entity=loaded_expense,
)
# await transaction.commit() # Explicit commit removed, context manager handles it.
return loaded_expense
@ -611,22 +620,28 @@ async def get_user_accessible_expenses(db: AsyncSession, user_id: int, skip: int
)
return result.scalars().all()
async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in: ExpenseUpdate) -> ExpenseModel:
async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in: ExpenseUpdate, current_user_id: int) -> ExpenseModel:
"""
Updates an existing expense.
Only allows updates to description, currency, and expense_date to avoid split complexities.
Requires version matching for optimistic locking.
Updates an expense. For now, only allows simple field updates.
More complex updates (like changing split logic) would require a more sophisticated approach.
"""
if expense_in.version is None:
raise InvalidOperationError("Version is required for updating an expense.")
if expense_db.version != expense_in.version:
raise InvalidOperationError(
f"Expense '{expense_db.description}' (ID: {expense_db.id}) has been modified. "
f"Your version is {expense_in.version}, current version is {expense_db.version}. Please refresh.",
# status_code=status.HTTP_409_CONFLICT # This would be for the API layer to set
f"Expense '{expense_db.description}' (ID: {expense_db.id}) cannot be updated. "
f"Your expected version {expense_in.version} does not match current version {expense_db.version}. Please refresh.",
)
update_data = expense_in.model_dump(exclude_unset=True, exclude={"version"}) # Exclude version itself from data
before_state = {c.name: getattr(expense_db, c.name) for c in expense_db.__table__.columns if c.name in expense_in.dict(exclude_unset=True)}
# A simple way to handle non-serializable types for JSON
for k, v in before_state.items():
if isinstance(v, (datetime, Decimal)):
before_state[k] = str(v)
update_data = expense_in.dict(exclude_unset=True, exclude={"version"})
# Fields that are safe to update without affecting splits or core logic
allowed_to_update = {"description", "currency", "expense_date"}
updated_something = False
@ -635,38 +650,41 @@ async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in:
setattr(expense_db, field, value)
updated_something = True
else:
# If any other field is present in the update payload, it's an invalid operation for this simple update
raise InvalidOperationError(f"Field '{field}' cannot be updated. Only {', '.join(allowed_to_update)} are allowed.")
if not updated_something and not expense_in.model_fields_set.intersection(allowed_to_update):
# No actual updatable fields were provided in the payload, even if others (like version) were.
# This could be a non-issue, or an indication of a misuse of the endpoint.
# For now, if only version was sent, we still increment if it matched.
pass # Or raise InvalidOperationError("No updatable fields provided.")
if not updated_something:
pass
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
async with db.begin_nested() if db.in_transaction() else db.begin():
expense_db.version += 1
expense_db.updated_at = datetime.now(timezone.utc) # Manually update timestamp
# db.add(expense_db) # Not strictly necessary as expense_db is already tracked by the session
expense_db.updated_at = datetime.now(timezone.utc)
await db.flush() # Persist changes to the DB and run constraints
await db.refresh(expense_db) # Refresh the object from the DB
await db.flush()
after_state = {c.name: getattr(expense_db, c.name) for c in expense_db.__table__.columns if c.name in update_data}
for k, v in after_state.items():
if isinstance(v, (datetime, Decimal)):
after_state[k] = str(v)
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="EXPENSE_UPDATED",
entity=expense_db,
details={"before": before_state, "after": after_state}
)
await db.refresh(expense_db)
return expense_db
except InvalidOperationError: # Re-raise validation errors to be handled by the caller
raise
except IntegrityError as e:
logger.error(f"Database integrity error during expense update for ID {expense_db.id}: {str(e)}", exc_info=True)
# The transaction context manager (begin_nested/begin) handles rollback.
raise DatabaseIntegrityError(f"Failed to update expense ID {expense_db.id} due to database integrity issue.") from e
except SQLAlchemyError as e: # Catch other SQLAlchemy errors
except SQLAlchemyError as e:
logger.error(f"Database transaction error during expense update for ID {expense_db.id}: {str(e)}", exc_info=True)
# The transaction context manager (begin_nested/begin) handles rollback.
raise DatabaseTransactionError(f"Failed to update expense ID {expense_db.id} due to a database transaction error.") from e
# No generic Exception catch here, let other unexpected errors propagate if not SQLAlchemy related.
async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, expected_version: Optional[int] = None) -> None:
async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, current_user_id: int, expected_version: Optional[int] = None) -> None:
"""
Deletes an expense. Requires version matching if expected_version is provided.
Associated ExpenseSplits are cascade deleted by the database foreign key constraint.
@ -675,23 +693,33 @@ async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, expected_ve
raise InvalidOperationError(
f"Expense '{expense_db.description}' (ID: {expense_db.id}) cannot be deleted. "
f"Your expected version {expected_version} does not match current version {expense_db.version}. Please refresh.",
# status_code=status.HTTP_409_CONFLICT
)
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
async with db.begin_nested() if db.in_transaction() else db.begin():
details = {c.name: getattr(expense_db, c.name) for c in expense_db.__table__.columns}
for k, v in details.items():
if isinstance(v, (datetime, Decimal)):
details[k] = str(v)
expense_id_for_log = expense_db.id
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="EXPENSE_DELETED",
entity=expense_db,
details=details
)
await db.delete(expense_db)
await db.flush() # Ensure the delete operation is sent to the database
except InvalidOperationError: # Re-raise validation errors
raise
await db.flush()
except IntegrityError as e:
logger.error(f"Database integrity error during expense deletion for ID {expense_db.id}: {str(e)}", exc_info=True)
# The transaction context manager (begin_nested/begin) handles rollback.
raise DatabaseIntegrityError(f"Failed to delete expense ID {expense_db.id} due to database integrity issue.") from e
except SQLAlchemyError as e: # Catch other SQLAlchemy errors
logger.error(f"Database transaction error during expense deletion for ID {expense_db.id}: {str(e)}", exc_info=True)
# The transaction context manager (begin_nested/begin) handles rollback.
raise DatabaseTransactionError(f"Failed to delete expense ID {expense_db.id} due to a database transaction error.") from e
logger.error(f"Database integrity error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True)
raise DatabaseIntegrityError(f"Failed to delete expense ID {expense_id_for_log} due to database integrity issue.") from e
except SQLAlchemyError as e:
logger.error(f"Database transaction error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to delete expense ID {expense_id_for_log} due to a database transaction error.") from e
return None
# Note: The InvalidOperationError is a simple ValueError placeholder.

View File

@ -1,15 +1,15 @@
# app/crud/group.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # For eager loading members
from sqlalchemy.orm import selectinload, joinedload, contains_eager
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List
from sqlalchemy import delete, func
import logging # Add logging import
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy import delete, func, and_, or_, update, desc
import logging
from datetime import datetime, timezone, timedelta
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel
from app.schemas.group import GroupCreate
from app.models import UserRoleEnum # Import enum
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel, List as ListModel, Chore as ChoreModel, ChoreAssignment as ChoreAssignmentModel
from app.schemas.group import GroupCreate, GroupPublic
from app.models import UserRoleEnum
from app.core.exceptions import (
GroupOperationError,
GroupNotFoundError,
@ -18,8 +18,10 @@ from app.core.exceptions import (
DatabaseQueryError,
DatabaseTransactionError,
GroupMembershipError,
GroupPermissionError # Import GroupPermissionError
GroupPermissionError,
PermissionDeniedError
)
from app.core.cache import cache
logger = logging.getLogger(__name__) # Initialize logger
@ -79,7 +81,8 @@ async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
.options(
selectinload(GroupModel.member_associations).options(
selectinload(UserGroupModel.user)
)
),
selectinload(GroupModel.chore_history) # Eager load chore history
)
)
return result.scalars().all()
@ -88,21 +91,18 @@ async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query user groups: {str(e)}")
@cache(expire_time=1800, key_prefix="group") # Cache for 30 minutes
async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupModel]:
"""Gets a single group by its ID, optionally loading members."""
try:
"""Get a group by its ID with caching, including member associations and chore history."""
result = await db.execute(
select(GroupModel)
.where(GroupModel.id == group_id)
.options(
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user)
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user),
selectinload(GroupModel.chore_history)
)
)
return result.scalars().first()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query group: {str(e)}")
return result.scalar_one_or_none()
async def is_user_member(db: AsyncSession, group_id: int, user_id: int) -> bool:
"""Checks if a user is a member of a specific group."""

82
be/app/crud/history.py Normal file
View File

@ -0,0 +1,82 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from typing import List, Optional, Any, Dict
from app.models import ChoreHistory, ChoreAssignmentHistory, ChoreHistoryEventTypeEnum, User, Chore, Group
from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentHistoryPublic
async def create_chore_history_entry(
db: AsyncSession,
*,
chore_id: Optional[int],
group_id: Optional[int],
changed_by_user_id: Optional[int],
event_type: ChoreHistoryEventTypeEnum,
event_data: Optional[Dict[str, Any]] = None,
) -> ChoreHistory:
"""Logs an event in the chore history."""
history_entry = ChoreHistory(
chore_id=chore_id,
group_id=group_id,
changed_by_user_id=changed_by_user_id,
event_type=event_type,
event_data=event_data or {},
)
db.add(history_entry)
await db.flush()
await db.refresh(history_entry)
return history_entry
async def create_assignment_history_entry(
db: AsyncSession,
*,
assignment_id: int,
changed_by_user_id: int,
event_type: ChoreHistoryEventTypeEnum,
event_data: Optional[Dict[str, Any]] = None,
) -> ChoreAssignmentHistory:
"""Logs an event in the chore assignment history."""
history_entry = ChoreAssignmentHistory(
assignment_id=assignment_id,
changed_by_user_id=changed_by_user_id,
event_type=event_type,
event_data=event_data or {},
)
db.add(history_entry)
await db.flush()
await db.refresh(history_entry)
return history_entry
async def get_chore_history(db: AsyncSession, chore_id: int) -> List[ChoreHistory]:
"""Gets all history for a specific chore."""
result = await db.execute(
select(ChoreHistory)
.where(ChoreHistory.chore_id == chore_id)
.options(selectinload(ChoreHistory.changed_by_user))
.order_by(ChoreHistory.timestamp.desc())
)
return result.scalars().all()
async def get_assignment_history(db: AsyncSession, assignment_id: int) -> List[ChoreAssignmentHistory]:
"""Gets all history for a specific assignment."""
result = await db.execute(
select(ChoreAssignmentHistory)
.where(ChoreAssignmentHistory.assignment_id == assignment_id)
.options(selectinload(ChoreAssignmentHistory.changed_by_user))
.order_by(ChoreAssignmentHistory.timestamp.desc())
)
return result.scalars().all()
async def get_group_chore_history(db: AsyncSession, group_id: int) -> List[ChoreHistory]:
"""Gets all chore-related history for a group, including chore-specific and group-level events."""
result = await db.execute(
select(ChoreHistory)
.where(ChoreHistory.group_id == group_id)
.options(
selectinload(ChoreHistory.changed_by_user),
selectinload(ChoreHistory.chore)
)
.order_by(ChoreHistory.timestamp.desc())
)
return result.scalars().all()

View File

@ -1,26 +1,24 @@
# app/crud/invite.py
import logging # Add logging import
import logging
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # Ensure selectinload is imported
from sqlalchemy import delete # Import delete statement
from sqlalchemy.orm import selectinload
from sqlalchemy import delete
from sqlalchemy.exc import SQLAlchemyError, OperationalError, IntegrityError
from typing import Optional
from app.models import Invite as InviteModel, Group as GroupModel, User as UserModel # Import related models for selectinload
from app.models import Invite as InviteModel, Group as GroupModel, User as UserModel
from app.core.exceptions import (
DatabaseConnectionError,
DatabaseIntegrityError,
DatabaseQueryError,
DatabaseTransactionError,
InviteOperationError # Add new specific exception
InviteOperationError
)
logger = logging.getLogger(__name__) # Initialize logger
logger = logging.getLogger(__name__)
# Invite codes should be reasonably unique, but handle potential collision
MAX_CODE_GENERATION_ATTEMPTS = 5
async def deactivate_all_active_invites_for_group(db: AsyncSession, group_id: int):
@ -35,15 +33,13 @@ async def deactivate_all_active_invites_for_group(db: AsyncSession, group_id: in
active_invites = result.scalars().all()
if not active_invites:
return # No active invites to deactivate
return
for invite in active_invites:
invite.is_active = False
db.add(invite)
await db.flush() # Flush changes within this transaction block
await db.flush()
# await db.flush() # Removed: Rely on caller to flush/commit
# No explicit commit here, assuming it's part of a larger transaction or caller handles commit.
except OperationalError as e:
logger.error(f"Database connection error deactivating invites for group {group_id}: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"DB connection error deactivating invites for group {group_id}: {str(e)}")
@ -51,12 +47,11 @@ async def deactivate_all_active_invites_for_group(db: AsyncSession, group_id: in
logger.error(f"Unexpected SQLAlchemy error deactivating invites for group {group_id}: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"DB transaction error deactivating invites for group {group_id}: {str(e)}")
async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expires_in_days: int = 365 * 100) -> Optional[InviteModel]: # Default to 100 years
async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expires_in_days: int = 365 * 100) -> Optional[InviteModel]:
"""Creates a new invite code for a group, deactivating any existing active ones for that group first."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin():
# Deactivate existing active invites for this group
await deactivate_all_active_invites_for_group(db, group_id)
expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)
@ -101,7 +96,7 @@ async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expire
raise InviteOperationError("Failed to load invite after creation and flush.")
return loaded_invite
except InviteOperationError: # Already specific, re-raise
except InviteOperationError:
raise
except IntegrityError as e:
logger.error(f"Database integrity error during invite creation for group {group_id}: {str(e)}", exc_info=True)
@ -121,13 +116,12 @@ async def get_active_invite_for_group(db: AsyncSession, group_id: int) -> Option
select(InviteModel).where(
InviteModel.group_id == group_id,
InviteModel.is_active == True,
InviteModel.expires_at > now # Still respect expiry, even if very long
InviteModel.expires_at > now
)
.order_by(InviteModel.created_at.desc()) # Get the most recent one if multiple (should not happen)
.limit(1)
.options(
selectinload(InviteModel.group), # Eager load group
selectinload(InviteModel.creator) # Eager load creator
selectinload(InviteModel.group),
selectinload(InviteModel.creator)
)
)
result = await db.execute(stmt)
@ -166,10 +160,9 @@ async def deactivate_invite(db: AsyncSession, invite: InviteModel) -> InviteMode
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
invite.is_active = False
db.add(invite) # Add to session to track change
await db.flush() # Persist is_active change
db.add(invite)
await db.flush()
# Re-fetch with relationships
stmt = (
select(InviteModel)
.where(InviteModel.id == invite.id)
@ -181,7 +174,7 @@ async def deactivate_invite(db: AsyncSession, invite: InviteModel) -> InviteMode
result = await db.execute(stmt)
updated_invite = result.scalar_one_or_none()
if updated_invite is None: # Should not happen as invite is passed in
if updated_invite is None:
raise InviteOperationError("Failed to load invite after deactivation.")
return updated_invite
@ -192,8 +185,3 @@ async def deactivate_invite(db: AsyncSession, invite: InviteModel) -> InviteMode
logger.error(f"Unexpected SQLAlchemy error deactivating invite: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"DB transaction error deactivating invite: {str(e)}")
# Ensure InviteOperationError is defined in app.core.exceptions
# Example: class InviteOperationError(AppException): pass
# Optional: Function to periodically delete old, inactive invites
# async def cleanup_old_invites(db: AsyncSession, older_than_days: int = 30): ...

View File

@ -1,15 +1,14 @@
# app/crud/item.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # Ensure selectinload is imported
from sqlalchemy import delete as sql_delete, update as sql_update # Use aliases
from sqlalchemy.orm import selectinload
from sqlalchemy import delete as sql_delete, update as sql_update
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List as PyList
from datetime import datetime, timezone
import logging # Add logging import
import logging
from sqlalchemy import func
from app.models import Item as ItemModel, User as UserModel # Import UserModel for type hints if needed for selectinload
from app.models import Item as ItemModel, User as UserModel
from app.schemas.item import ItemCreate, ItemUpdate
from app.core.exceptions import (
ItemNotFoundError,
@ -18,16 +17,15 @@ from app.core.exceptions import (
DatabaseQueryError,
DatabaseTransactionError,
ConflictError,
ItemOperationError # Add if specific item operation errors are needed
ItemOperationError
)
logger = logging.getLogger(__name__) # Initialize logger
logger = logging.getLogger(__name__)
async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_id: int) -> ItemModel:
"""Creates a new item record for a specific list, setting its position."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
# Get the current max position in the list
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: # Start transaction
max_pos_stmt = select(func.max(ItemModel.position)).where(ItemModel.list_id == list_id)
max_pos_result = await db.execute(max_pos_stmt)
max_pos = max_pos_result.scalar_one_or_none() or 0
@ -35,29 +33,28 @@ async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_
db_item = ItemModel(
name=item_in.name,
quantity=item_in.quantity,
category_id=item_in.category_id,
list_id=list_id,
added_by_id=user_id,
is_complete=False,
position=max_pos + 1 # Set the new position
position=max_pos + 1
)
db.add(db_item)
await db.flush() # Assigns ID
await db.flush()
# Re-fetch with relationships
stmt = (
select(ItemModel)
.where(ItemModel.id == db_item.id)
.options(
selectinload(ItemModel.added_by_user),
selectinload(ItemModel.completed_by_user) # Will be None but loads relationship
selectinload(ItemModel.completed_by_user)
)
)
result = await db.execute(stmt)
loaded_item = result.scalar_one_or_none()
if loaded_item is None:
# await transaction.rollback() # Redundant, context manager handles rollback on exception
raise ItemOperationError("Failed to load item after creation.") # Define ItemOperationError
raise ItemOperationError("Failed to load item after creation.")
return loaded_item
except IntegrityError as e:
@ -69,8 +66,6 @@ async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during item creation: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to create item: {str(e)}")
# Removed generic Exception block as SQLAlchemyError should cover DB issues,
# and context manager handles rollback.
async def get_items_by_list_id(db: AsyncSession, list_id: int) -> PyList[ItemModel]:
"""Gets all items belonging to a specific list, ordered by creation time."""
@ -100,7 +95,7 @@ async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]:
.options(
selectinload(ItemModel.added_by_user),
selectinload(ItemModel.completed_by_user),
selectinload(ItemModel.list) # Often useful to get the parent list
selectinload(ItemModel.list)
)
)
result = await db.execute(stmt)
@ -113,7 +108,7 @@ async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]:
async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel:
"""Updates an existing item record, checking for version conflicts and handling reordering."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: # Start transaction
if item_db.version != item_in.version:
raise ConflictError(
f"Item '{item_db.name}' (ID: {item_db.id}) has been modified. "
@ -122,31 +117,26 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
update_data = item_in.model_dump(exclude_unset=True, exclude={'version'})
# --- Handle Reordering ---
if 'position' in update_data:
new_position = update_data.pop('position') # Remove from update_data to handle separately
if 'category_id' in update_data:
item_db.category_id = update_data.pop('category_id')
if 'position' in update_data:
new_position = update_data.pop('position')
# We need the full list to reorder, making sure it's loaded and ordered
list_id = item_db.list_id
stmt = select(ItemModel).where(ItemModel.list_id == list_id).order_by(ItemModel.position.asc(), ItemModel.created_at.asc())
result = await db.execute(stmt)
items_in_list = result.scalars().all()
# Find the item to move
item_to_move = next((it for it in items_in_list if it.id == item_db.id), None)
if item_to_move:
items_in_list.remove(item_to_move)
# Insert at the new position (adjust for 1-based index from frontend)
# Clamp position to be within bounds
insert_pos = max(0, min(new_position - 1, len(items_in_list)))
items_in_list.insert(insert_pos, item_to_move)
# Re-assign positions
for i, item in enumerate(items_in_list):
item.position = i + 1
# --- End Handle Reordering ---
if 'is_complete' in update_data:
if update_data['is_complete'] is True:
if item_db.completed_by_id is None:
@ -158,10 +148,9 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
setattr(item_db, key, value)
item_db.version += 1
db.add(item_db) # Mark as dirty
db.add(item_db)
await db.flush()
# Re-fetch with relationships
stmt = (
select(ItemModel)
.where(ItemModel.id == item_db.id)
@ -174,8 +163,7 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
result = await db.execute(stmt)
updated_item = result.scalar_one_or_none()
if updated_item is None: # Should not happen
# Rollback will be handled by context manager on raise
if updated_item is None:
raise ItemOperationError("Failed to load item after update.")
return updated_item
@ -185,7 +173,7 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
except OperationalError as e:
logger.error(f"Database connection error while updating item: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while updating item: {str(e)}")
except ConflictError: # Re-raise ConflictError, rollback handled by context manager
except ConflictError:
raise
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during item update: {str(e)}", exc_info=True)
@ -196,14 +184,9 @@ async def delete_item(db: AsyncSession, item_db: ItemModel) -> None:
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
await db.delete(item_db)
# await transaction.commit() # Removed
# No return needed for None
except OperationalError as e:
logger.error(f"Database connection error while deleting item: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while deleting item: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while deleting item: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to delete item: {str(e)}")
# Ensure ItemOperationError is defined in app.core.exceptions if used
# Example: class ItemOperationError(AppException): pass

View File

@ -1,11 +1,11 @@
# app/crud/list.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy import or_, and_, delete as sql_delete, func as sql_func, desc
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List as PyList
import logging # Add logging import
import logging
from datetime import datetime, timezone
from app.schemas.list import ListStatus
from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel
@ -22,12 +22,12 @@ from app.core.exceptions import (
ListOperationError
)
logger = logging.getLogger(__name__) # Initialize logger
logger = logging.getLogger(__name__)
async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel:
"""Creates a new list record."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: # Start transaction
db_list = ListModel(
name=list_in.name,
description=list_in.description,
@ -36,16 +36,14 @@ async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) ->
is_complete=False
)
db.add(db_list)
await db.flush() # Assigns ID
await db.flush()
# Re-fetch with relationships for the response
stmt = (
select(ListModel)
.where(ListModel.id == db_list.id)
.options(
selectinload(ListModel.creator),
selectinload(ListModel.group)
# selectinload(ListModel.items) # Optionally add if items are always needed in response
)
)
result = await db.execute(stmt)
@ -65,7 +63,7 @@ async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) ->
logger.error(f"Unexpected SQLAlchemy error during list creation: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to create list: {str(e)}")
async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel]:
async def get_lists_for_user(db: AsyncSession, user_id: int, include_archived: bool = False) -> PyList[ListModel]:
"""Gets all lists accessible by a user."""
try:
group_ids_result = await db.execute(
@ -79,19 +77,19 @@ async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel
if user_group_ids:
conditions.append(ListModel.group_id.in_(user_group_ids))
query = (
select(ListModel)
.where(or_(*conditions))
.options(
query = select(ListModel).where(or_(*conditions))
if not include_archived:
query = query.where(ListModel.archived_at.is_(None))
query = query.options(
selectinload(ListModel.creator),
selectinload(ListModel.group),
selectinload(ListModel.items).options(
joinedload(ItemModel.added_by_user),
joinedload(ItemModel.completed_by_user)
)
)
.order_by(ListModel.updated_at.desc())
)
).order_by(ListModel.updated_at.desc())
result = await db.execute(query)
return result.scalars().all()
@ -129,7 +127,7 @@ async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate)
"""Updates an existing list record, checking for version conflicts."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
if list_db.version != list_in.version: # list_db here is the one passed in, pre-loaded by API layer
if list_db.version != list_in.version:
raise ConflictError(
f"List '{list_db.name}' (ID: {list_db.id}) has been modified. "
f"Your version is {list_in.version}, current version is {list_db.version}. Please refresh."
@ -145,20 +143,18 @@ async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate)
db.add(list_db) # Add the already attached list_db to mark it dirty for the session
await db.flush()
# Re-fetch with relationships for the response
stmt = (
select(ListModel)
.where(ListModel.id == list_db.id)
.options(
selectinload(ListModel.creator),
selectinload(ListModel.group)
# selectinload(ListModel.items) # Optionally add if items are always needed in response
)
)
result = await db.execute(stmt)
updated_list = result.scalar_one_or_none()
if updated_list is None: # Should not happen
if updated_list is None:
raise ListOperationError("Failed to load list after update.")
return updated_list
@ -174,17 +170,35 @@ async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate)
logger.error(f"Unexpected SQLAlchemy error during list update: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to update list: {str(e)}")
async def delete_list(db: AsyncSession, list_db: ListModel) -> None:
"""Deletes a list record. Version check should be done by the caller (API endpoint)."""
async def archive_list(db: AsyncSession, list_db: ListModel) -> ListModel:
"""Archives a list record by setting the archived_at timestamp."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: # Standardize transaction
await db.delete(list_db)
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
list_db.archived_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(list_db)
return list_db
except OperationalError as e:
logger.error(f"Database connection error while deleting list: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while deleting list: {str(e)}")
logger.error(f"Database connection error while archiving list: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while archiving list: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while deleting list: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to delete list: {str(e)}")
logger.error(f"Unexpected SQLAlchemy error while archiving list: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to archive list: {str(e)}")
async def unarchive_list(db: AsyncSession, list_db: ListModel) -> ListModel:
"""Unarchives a list record by setting the archived_at timestamp to None."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
list_db.archived_at = None
await db.flush()
await db.refresh(list_db)
return list_db
except OperationalError as e:
logger.error(f"Database connection error while unarchiving list: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while unarchiving list: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while unarchiving list: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to unarchive list: {str(e)}")
async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, require_creator: bool = False) -> ListModel:
"""Fetches a list and verifies user permission."""
@ -257,7 +271,6 @@ async def get_list_by_name_and_group(
Used for conflict resolution when creating lists.
"""
try:
# Base query for the list itself
base_query = select(ListModel).where(ListModel.name == name)
if group_id is not None:
@ -265,7 +278,6 @@ async def get_list_by_name_and_group(
else:
base_query = base_query.where(ListModel.group_id.is_(None))
# Add eager loading for common relationships
base_query = base_query.options(
selectinload(ListModel.creator),
selectinload(ListModel.group)
@ -277,19 +289,17 @@ async def get_list_by_name_and_group(
if not target_list:
return None
# Permission check
is_creator = target_list.created_by_id == user_id
if is_creator:
return target_list
if target_list.group_id:
from app.crud.group import is_user_member # Assuming this is a quick check not needing its own transaction
from app.crud.group import is_user_member
is_member_of_group = await is_user_member(db, group_id=target_list.group_id, user_id=user_id)
if is_member_of_group:
return target_list
# If not creator and (not a group list or not a member of the group list)
return None
except OperationalError as e:
@ -306,21 +316,16 @@ async def get_lists_statuses_by_ids(db: AsyncSession, list_ids: PyList[int], use
return []
try:
# First, get the groups the user is a member of
group_ids_result = await db.execute(
select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id)
)
user_group_ids = group_ids_result.scalars().all()
# Build the permission logic
permission_filter = or_(
# User is the creator of the list
and_(ListModel.created_by_id == user_id, ListModel.group_id.is_(None)),
# List belongs to a group the user is a member of
ListModel.group_id.in_(user_group_ids)
)
# Main query to get list data and item counts
query = (
select(
ListModel.id,
@ -340,11 +345,7 @@ async def get_lists_statuses_by_ids(db: AsyncSession, list_ids: PyList[int], use
result = await db.execute(query)
# The result will be rows of (id, updated_at, item_count).
# We need to verify that all requested list_ids that the user *should* have access to are present.
# The filter in the query already handles permissions.
return result.all() # Returns a list of Row objects with id, updated_at, item_count
return result.all()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")

103
be/app/crud/schedule.py Normal file
View File

@ -0,0 +1,103 @@
import logging
from datetime import date, timedelta
from typing import List
from itertools import cycle
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.models import Chore, ChoreAssignment, UserGroup, ChoreTypeEnum, ChoreHistoryEventTypeEnum
from app.crud.group import get_group_by_id
from app.crud.history import create_chore_history_entry
from app.core.exceptions import GroupNotFoundError, ChoreOperationError
logger = logging.getLogger(__name__)
async def generate_group_chore_schedule(
db: AsyncSession,
*,
group_id: int,
start_date: date,
end_date: date,
user_id: int,
member_ids: List[int] = None
) -> List[ChoreAssignment]:
"""
Generates a round-robin chore schedule for all group chores within a date range.
"""
if start_date > end_date:
raise ChoreOperationError("Start date cannot be after end date.")
group = await get_group_by_id(db, group_id)
if not group:
raise GroupNotFoundError(group_id)
if not member_ids:
members_result = await db.execute(
select(UserGroup.user_id).where(UserGroup.group_id == group_id)
)
member_ids = members_result.scalars().all()
if not member_ids:
raise ChoreOperationError("Cannot generate schedule with no members.")
chores_result = await db.execute(
select(Chore).where(Chore.group_id == group_id, Chore.type == ChoreTypeEnum.group)
)
group_chores = chores_result.scalars().all()
if not group_chores:
logger.info(f"No chores found in group {group_id} to generate a schedule for.")
return []
member_cycle = cycle(member_ids)
new_assignments = []
current_date = start_date
while current_date <= end_date:
for chore in group_chores:
if start_date <= chore.next_due_date <= end_date:
existing_assignment_result = await db.execute(
select(ChoreAssignment.id)
.where(ChoreAssignment.chore_id == chore.id, ChoreAssignment.due_date == chore.next_due_date)
.limit(1)
)
if existing_assignment_result.scalar_one_or_none():
logger.info(f"Skipping assignment for chore '{chore.name}' on {chore.next_due_date} as it already exists.")
continue
assigned_to_user_id = next(member_cycle)
assignment = ChoreAssignment(
chore_id=chore.id,
assigned_to_user_id=assigned_to_user_id,
due_date=chore.next_due_date,
is_complete=False
)
db.add(assignment)
new_assignments.append(assignment)
logger.info(f"Created assignment for chore '{chore.name}' to user {assigned_to_user_id} on {chore.next_due_date}")
current_date += timedelta(days=1)
if not new_assignments:
logger.info(f"No new assignments were generated for group {group_id} in the specified date range.")
return []
await create_chore_history_entry(
db,
chore_id=None,
group_id=group_id,
changed_by_user_id=user_id,
event_type=ChoreHistoryEventTypeEnum.SCHEDULE_GENERATED,
event_data={
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"member_ids": member_ids,
"assignments_created": len(new_assignments)
}
)
await db.flush()
for assign in new_assignments:
await db.refresh(assign)
return new_assignments

View File

@ -1,4 +1,3 @@
# app/crud/settlement.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload, joinedload
@ -7,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError, OperationalError, IntegrityError
from decimal import Decimal, ROUND_HALF_UP
from typing import List as PyList, Optional, Sequence
from datetime import datetime, timezone
import logging # Add logging import
import logging
from app.models import (
Settlement as SettlementModel,
@ -27,8 +26,9 @@ from app.core.exceptions import (
SettlementOperationError,
ConflictError
)
from app.crud.audit import create_financial_audit_log
logger = logging.getLogger(__name__) # Initialize logger
logger = logging.getLogger(__name__)
async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, current_user_id: int) -> SettlementModel:
"""Creates a new settlement record."""
@ -49,13 +49,6 @@ async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, c
if not group:
raise GroupNotFoundError(settlement_in.group_id)
# Permission check example (can be in API layer too)
# if current_user_id not in [payer.id, payee.id]:
# is_member_stmt = select(UserGroupModel.id).where(UserGroupModel.group_id == group.id, UserGroupModel.user_id == current_user_id).limit(1)
# is_member_result = await db.execute(is_member_stmt)
# if not is_member_result.scalar_one_or_none():
# raise InvalidOperationError("Settlement recorder must be part of the group or one of the parties.")
db_settlement = SettlementModel(
group_id=settlement_in.group_id,
paid_by_user_id=settlement_in.paid_by_user_id,
@ -68,7 +61,6 @@ async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, c
db.add(db_settlement)
await db.flush()
# Re-fetch with relationships
stmt = (
select(SettlementModel)
.where(SettlementModel.id == db_settlement.id)
@ -85,10 +77,15 @@ async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, c
if loaded_settlement is None:
raise SettlementOperationError("Failed to load settlement after creation.")
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="SETTLEMENT_CREATED",
entity=loaded_settlement,
)
return loaded_settlement
except (UserNotFoundError, GroupNotFoundError, InvalidOperationError) as e:
# These are validation errors, re-raise them.
# If a transaction was started, context manager handles rollback.
raise
except IntegrityError as e:
logger.error(f"Database integrity error during settlement creation: {str(e)}", exc_info=True)
@ -115,10 +112,8 @@ async def get_settlement_by_id(db: AsyncSession, settlement_id: int) -> Optional
)
return result.scalars().first()
except OperationalError as e:
# Optional: logger.warning or info if needed for read operations
raise DatabaseConnectionError(f"DB connection error fetching settlement: {str(e)}")
except SQLAlchemyError as e:
# Optional: logger.warning or info if needed for read operations
raise DatabaseQueryError(f"DB query error fetching settlement: {str(e)}")
async def get_settlements_for_group(db: AsyncSession, group_id: int, skip: int = 0, limit: int = 100) -> Sequence[SettlementModel]:
@ -173,7 +168,7 @@ async def get_settlements_involving_user(
raise DatabaseQueryError(f"DB query error fetching user settlements: {str(e)}")
async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, settlement_in: SettlementUpdate) -> SettlementModel:
async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, settlement_in: SettlementUpdate, current_user_id: int) -> SettlementModel:
"""
Updates an existing settlement.
Only allows updates to description and settlement_date.
@ -183,10 +178,6 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
"""
try:
async with db.begin_nested() if db.in_transaction() else db.begin():
# Ensure the settlement_db passed is managed by the current session if not already.
# This is usually true if fetched by an endpoint dependency using the same session.
# If not, `db.add(settlement_db)` might be needed before modification if it's detached.
if not hasattr(settlement_db, 'version') or not hasattr(settlement_in, 'version'):
raise InvalidOperationError("Version field is missing in model or input for optimistic locking.")
@ -196,6 +187,11 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
f"Your version {settlement_in.version} does not match current version {settlement_db.version}. Please refresh."
)
before_state = {c.name: getattr(settlement_db, c.name) for c in settlement_db.__table__.columns if c.name in settlement_in.dict(exclude_unset=True)}
for k, v in before_state.items():
if isinstance(v, (datetime, Decimal)):
before_state[k] = str(v)
update_data = settlement_in.model_dump(exclude_unset=True, exclude={"version"})
allowed_to_update = {"description", "settlement_date"}
updated_something = False
@ -204,22 +200,14 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
if field in allowed_to_update:
setattr(settlement_db, field, value)
updated_something = True
# Silently ignore fields not allowed to update or raise error:
# else:
# raise InvalidOperationError(f"Field '{field}' cannot be updated.")
if not updated_something and not settlement_in.model_fields_set.intersection(allowed_to_update):
# No updatable fields were actually provided, or they didn't change
# Still, we might want to return the re-loaded settlement if version matched.
pass
settlement_db.version += 1
settlement_db.updated_at = datetime.now(timezone.utc) # Ensure model has this field
db.add(settlement_db) # Mark as dirty
settlement_db.updated_at = datetime.now(timezone.utc)
await db.flush()
# Re-fetch with relationships
stmt = (
select(SettlementModel)
.where(SettlementModel.id == settlement_db.id)
@ -233,11 +221,24 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
result = await db.execute(stmt)
updated_settlement = result.scalar_one_or_none()
if updated_settlement is None: # Should not happen
if updated_settlement is None:
raise SettlementOperationError("Failed to load settlement after update.")
after_state = {c.name: getattr(updated_settlement, c.name) for c in updated_settlement.__table__.columns if c.name in update_data}
for k, v in after_state.items():
if isinstance(v, (datetime, Decimal)):
after_state[k] = str(v)
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="SETTLEMENT_UPDATED",
entity=updated_settlement,
details={"before": before_state, "after": after_state}
)
return updated_settlement
except ConflictError as e: # ConflictError should be defined in exceptions
except ConflictError as e:
raise
except InvalidOperationError as e:
raise
@ -252,7 +253,7 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
raise DatabaseTransactionError(f"DB transaction error during settlement update: {str(e)}")
async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, expected_version: Optional[int] = None) -> None:
async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, current_user_id: int, expected_version: Optional[int] = None) -> None:
"""
Deletes a settlement. Requires version matching if expected_version is provided.
Assumes SettlementModel has a version field.
@ -261,13 +262,26 @@ async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, ex
async with db.begin_nested() if db.in_transaction() else db.begin():
if expected_version is not None:
if not hasattr(settlement_db, 'version') or settlement_db.version != expected_version:
raise ConflictError( # Make sure ConflictError is defined
raise ConflictError(
f"Settlement (ID: {settlement_db.id}) cannot be deleted. "
f"Expected version {expected_version} does not match current version {settlement_db.version}. Please refresh."
)
details = {c.name: getattr(settlement_db, c.name) for c in settlement_db.__table__.columns}
for k, v in details.items():
if isinstance(v, (datetime, Decimal)):
details[k] = str(v)
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="SETTLEMENT_DELETED",
entity=settlement_db,
details=details
)
await db.delete(settlement_db)
except ConflictError as e: # ConflictError should be defined
except ConflictError as e:
raise
except OperationalError as e:
logger.error(f"Database connection error during settlement deletion: {str(e)}", exc_info=True)
@ -275,7 +289,3 @@ async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, ex
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during settlement deletion: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"DB transaction error during settlement deletion: {str(e)}")
# Ensure SettlementOperationError and ConflictError are defined in app.core.exceptions
# Example: class SettlementOperationError(AppException): pass
# Example: class ConflictError(AppException): status_code = 409

View File

@ -14,9 +14,8 @@ from app.models import (
ExpenseSplitStatusEnum,
ExpenseOverallStatusEnum,
)
# Placeholder for Pydantic schema - actual schema definition is a later step
# from app.schemas.settlement_activity import SettlementActivityCreate # Assuming this path
from pydantic import BaseModel # Using pydantic BaseModel directly for the placeholder
from pydantic import BaseModel
from app.crud.audit import create_financial_audit_log
class SettlementActivityCreatePlaceholder(BaseModel):
@ -26,8 +25,7 @@ class SettlementActivityCreatePlaceholder(BaseModel):
paid_at: Optional[datetime] = None
class Config:
orm_mode = True # Pydantic V1 style orm_mode
# from_attributes = True # Pydantic V2 style
orm_mode = True
async def update_expense_split_status(db: AsyncSession, expense_split_id: int) -> Optional[ExpenseSplit]:
@ -35,7 +33,6 @@ async def update_expense_split_status(db: AsyncSession, expense_split_id: int) -
Updates the status of an ExpenseSplit based on its settlement activities.
Also updates the overall status of the parent Expense.
"""
# Fetch the ExpenseSplit with its related settlement_activities and the parent expense
result = await db.execute(
select(ExpenseSplit)
.options(
@ -47,18 +44,13 @@ async def update_expense_split_status(db: AsyncSession, expense_split_id: int) -
expense_split = result.scalar_one_or_none()
if not expense_split:
# Or raise an exception, depending on desired error handling
return None
# Calculate total_paid from all settlement_activities for that split
total_paid = sum(activity.amount_paid for activity in expense_split.settlement_activities)
total_paid = Decimal(total_paid).quantize(Decimal("0.01")) # Ensure two decimal places
total_paid = Decimal(total_paid).quantize(Decimal("0.01"))
# Compare total_paid with ExpenseSplit.owed_amount
if total_paid >= expense_split.owed_amount:
expense_split.status = ExpenseSplitStatusEnum.paid
# Set paid_at to the latest relevant SettlementActivity or current time
# For simplicity, let's find the latest paid_at from activities, or use now()
latest_paid_at = None
if expense_split.settlement_activities:
latest_paid_at = max(act.paid_at for act in expense_split.settlement_activities if act.paid_at)
@ -66,13 +58,13 @@ async def update_expense_split_status(db: AsyncSession, expense_split_id: int) -
expense_split.paid_at = latest_paid_at if latest_paid_at else datetime.now(timezone.utc)
elif total_paid > 0:
expense_split.status = ExpenseSplitStatusEnum.partially_paid
expense_split.paid_at = None # Clear paid_at if not fully paid
expense_split.paid_at = None
else: # total_paid == 0
expense_split.status = ExpenseSplitStatusEnum.unpaid
expense_split.paid_at = None # Clear paid_at
expense_split.paid_at = None
await db.flush()
await db.refresh(expense_split, attribute_names=['status', 'paid_at', 'expense']) # Refresh to get updated data and related expense
await db.refresh(expense_split, attribute_names=['status', 'paid_at', 'expense'])
return expense_split
@ -81,18 +73,16 @@ async def update_expense_overall_status(db: AsyncSession, expense_id: int) -> Op
"""
Updates the overall_status of an Expense based on the status of its splits.
"""
# Fetch the Expense with its related splits
result = await db.execute(
select(Expense).options(selectinload(Expense.splits)).where(Expense.id == expense_id)
)
expense = result.scalar_one_or_none()
if not expense:
# Or raise an exception
return None
if not expense.splits: # No splits, should not happen for a valid expense but handle defensively
expense.overall_settlement_status = ExpenseOverallStatusEnum.unpaid # Or some other default/error state
if not expense.splits:
expense.overall_settlement_status = ExpenseOverallStatusEnum.unpaid
await db.flush()
await db.refresh(expense)
return expense
@ -107,14 +97,14 @@ async def update_expense_overall_status(db: AsyncSession, expense_id: int) -> Op
num_paid_splits += 1
elif split.status == ExpenseSplitStatusEnum.partially_paid:
num_partially_paid_splits += 1
else: # unpaid
else:
num_unpaid_splits += 1
if num_paid_splits == num_splits:
expense.overall_settlement_status = ExpenseOverallStatusEnum.paid
elif num_unpaid_splits == num_splits:
expense.overall_settlement_status = ExpenseOverallStatusEnum.unpaid
else: # Mix of paid, partially_paid, or unpaid but not all unpaid/paid
else:
expense.overall_settlement_status = ExpenseOverallStatusEnum.partially_paid
await db.flush()
@ -130,43 +120,40 @@ async def create_settlement_activity(
"""
Creates a new settlement activity, then updates the parent expense split and expense statuses.
"""
# Validate ExpenseSplit
split_result = await db.execute(select(ExpenseSplit).where(ExpenseSplit.id == settlement_activity_in.expense_split_id))
expense_split = split_result.scalar_one_or_none()
if not expense_split:
# Consider raising an HTTPException in an API layer
return None # ExpenseSplit not found
return None
# Validate User (paid_by_user_id)
user_result = await db.execute(select(User).where(User.id == settlement_activity_in.paid_by_user_id))
paid_by_user = user_result.scalar_one_or_none()
if not paid_by_user:
return None # User not found
# Create SettlementActivity instance
db_settlement_activity = SettlementActivity(
expense_split_id=settlement_activity_in.expense_split_id,
paid_by_user_id=settlement_activity_in.paid_by_user_id,
amount_paid=settlement_activity_in.amount_paid,
paid_at=settlement_activity_in.paid_at if settlement_activity_in.paid_at else datetime.now(timezone.utc),
created_by_user_id=current_user_id # The user recording the activity
created_by_user_id=current_user_id
)
db.add(db_settlement_activity)
await db.flush() # Flush to get the ID for db_settlement_activity
await db.flush()
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="SETTLEMENT_ACTIVITY_CREATED",
entity=db_settlement_activity,
)
# Update statuses
updated_split = await update_expense_split_status(db, expense_split_id=db_settlement_activity.expense_split_id)
if updated_split and updated_split.expense_id:
await update_expense_overall_status(db, expense_id=updated_split.expense_id)
else:
# This case implies update_expense_split_status returned None or expense_id was missing.
# This could be a problem, consider logging or raising an error.
# For now, the transaction would roll back if an exception is raised.
# If not raising, the overall status update might be skipped.
pass # Or handle error
await db.refresh(db_settlement_activity, attribute_names=['split', 'payer', 'creator']) # Refresh to load relationships
pass
return db_settlement_activity
@ -180,9 +167,9 @@ async def get_settlement_activity_by_id(
result = await db.execute(
select(SettlementActivity)
.options(
selectinload(SettlementActivity.split).selectinload(ExpenseSplit.expense), # Load split and its parent expense
selectinload(SettlementActivity.payer), # Load the user who paid
selectinload(SettlementActivity.creator) # Load the user who created the record
selectinload(SettlementActivity.split).selectinload(ExpenseSplit.expense),
selectinload(SettlementActivity.payer),
selectinload(SettlementActivity.creator)
)
.where(SettlementActivity.id == settlement_activity_id)
)
@ -199,8 +186,8 @@ async def get_settlement_activities_for_split(
select(SettlementActivity)
.where(SettlementActivity.expense_split_id == expense_split_id)
.options(
selectinload(SettlementActivity.payer), # Load the user who paid
selectinload(SettlementActivity.creator) # Load the user who created the record
selectinload(SettlementActivity.payer),
selectinload(SettlementActivity.creator)
)
.order_by(SettlementActivity.paid_at.desc(), SettlementActivity.created_at.desc())
.offset(skip)

View File

@ -1,12 +1,11 @@
# app/crud/user.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # Ensure selectinload is imported
from sqlalchemy.orm import selectinload
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional
import logging # Add logging import
import logging
from app.models import User as UserModel, UserGroup as UserGroupModel, Group as GroupModel # Import related models for selectinload
from app.models import User as UserModel, UserGroup as UserGroupModel
from app.schemas.user import UserCreate
from app.core.security import hash_password
from app.core.exceptions import (
@ -16,23 +15,19 @@ from app.core.exceptions import (
DatabaseIntegrityError,
DatabaseQueryError,
DatabaseTransactionError,
UserOperationError # Add if specific user operation errors are needed
UserOperationError
)
logger = logging.getLogger(__name__) # Initialize logger
logger = logging.getLogger(__name__)
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[UserModel]:
"""Fetches a user from the database by email, with common relationships."""
try:
# db.begin() is not strictly necessary for a single read, but ensures atomicity if multiple reads were added.
# For a single select, it can be omitted if preferred, session handles connection.
stmt = (
select(UserModel)
.filter(UserModel.email == email)
.options(
selectinload(UserModel.group_associations).selectinload(UserGroupModel.group), # Groups user is member of
selectinload(UserModel.created_groups) # Groups user created
# Add other relationships as needed by UserPublic schema
selectinload(UserModel.group_associations).selectinload(UserGroupModel.group),
)
)
result = await db.execute(stmt)
@ -44,34 +39,33 @@ async def get_user_by_email(db: AsyncSession, email: str) -> Optional[UserModel]
logger.error(f"Unexpected SQLAlchemy error while fetching user by email '{email}': {str(e)}", exc_info=True)
raise DatabaseQueryError(f"Failed to query user: {str(e)}")
async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel:
async def create_user(db: AsyncSession, user_in: UserCreate, is_guest: bool = False) -> UserModel:
"""Creates a new user record in the database with common relationships loaded."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
_hashed_password = hash_password(user_in.password)
db_user = UserModel(
email=user_in.email,
hashed_password=_hashed_password, # Field name in model is hashed_password
name=user_in.name
hashed_password=_hashed_password,
name=user_in.name,
is_guest=is_guest
)
db.add(db_user)
await db.flush() # Flush to get DB-generated values like ID
await db.flush()
# Re-fetch with relationships
stmt = (
select(UserModel)
.where(UserModel.id == db_user.id)
.options(
selectinload(UserModel.group_associations).selectinload(UserGroupModel.group),
selectinload(UserModel.created_groups)
# Add other relationships as needed by UserPublic schema
)
)
result = await db.execute(stmt)
loaded_user = result.scalar_one_or_none()
if loaded_user is None:
raise UserOperationError("Failed to load user after creation.") # Define UserOperationError
raise UserOperationError("Failed to load user after creation.")
return loaded_user
except IntegrityError as e:
@ -85,6 +79,3 @@ async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel:
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during user creation for email '{user_in.email}': {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to create user due to other DB error: {str(e)}")
# Ensure UserOperationError is defined in app.core.exceptions if used
# Example: class UserOperationError(AppException): pass

View File

@ -1,24 +1,18 @@
# app/database.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, declarative_base
from app.config import settings
# Ensure DATABASE_URL is set before proceeding
if not settings.DATABASE_URL:
raise ValueError("DATABASE_URL is not configured in settings.")
# Create the SQLAlchemy async engine
# pool_recycle=3600 helps prevent stale connections on some DBs
engine = create_async_engine(
settings.DATABASE_URL,
echo=False, # Disable SQL query logging for production (use DEBUG log level to enable)
future=True, # Use SQLAlchemy 2.0 style features
pool_recycle=3600, # Optional: recycle connections after 1 hour
pool_pre_ping=True # Add this line to ensure connections are live
echo=False,
future=True,
pool_recycle=3600,
pool_pre_ping=True
)
# Create a configured "Session" class
# expire_on_commit=False prevents attributes from expiring after commit
AsyncSessionLocal = sessionmaker(
bind=engine,
class_=AsyncSession,
@ -27,10 +21,8 @@ AsyncSessionLocal = sessionmaker(
autocommit=False,
)
# Base class for our ORM models
Base = declarative_base()
# Dependency to get DB session in path operations
async def get_session() -> AsyncSession: # type: ignore
"""
Dependency function that yields an AsyncSession for read-only operations.
@ -38,7 +30,6 @@ async def get_session() -> AsyncSession: # type: ignore
"""
async with AsyncSessionLocal() as session:
yield session
# The 'async with' block handles session.close() automatically.
async def get_transactional_session() -> AsyncSession: # type: ignore
"""
@ -51,7 +42,5 @@ async def get_transactional_session() -> AsyncSession: # type: ignore
async with AsyncSessionLocal() as session:
async with session.begin():
yield session
# Transaction is automatically committed on success or rolled back on exception
# Alias for backward compatibility
get_db = get_session

View File

@ -1,4 +1,2 @@
from app.database import AsyncSessionLocal
# Export the async session factory
async_session = AsyncSessionLocal

View File

@ -15,18 +15,15 @@ async def generate_recurring_expenses(db: AsyncSession) -> None:
Should be run daily to check for and create new recurring expenses.
"""
try:
# Get all active recurring expenses that need to be generated
now = datetime.utcnow()
query = select(Expense).join(RecurrencePattern).where(
and_(
Expense.is_recurring == True,
Expense.next_occurrence <= now,
# Check if we haven't reached max occurrences
(
(RecurrencePattern.max_occurrences == None) |
(RecurrencePattern.max_occurrences > 0)
),
# Check if we haven't reached end date
(
(RecurrencePattern.end_date == None) |
(RecurrencePattern.end_date > now)
@ -54,12 +51,10 @@ async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None:
if not pattern:
return
# Calculate next occurrence date
next_date = _calculate_next_occurrence(expense.next_occurrence, pattern)
if not next_date:
return
# Create new expense based on template
new_expense = ExpenseCreate(
description=expense.description,
total_amount=expense.total_amount,
@ -70,14 +65,12 @@ async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None:
group_id=expense.group_id,
item_id=expense.item_id,
paid_by_user_id=expense.paid_by_user_id,
is_recurring=False, # Generated expenses are not recurring
splits_in=None # Will be generated based on split_type
is_recurring=False,
splits_in=None
)
# Create the new expense
created_expense = await create_expense(db, new_expense, expense.created_by_user_id)
# Update the original expense
expense.last_occurrence = next_date
expense.next_occurrence = _calculate_next_occurrence(next_date, pattern)
@ -98,7 +91,6 @@ def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePatter
if not pattern.days_of_week:
return current_date + timedelta(weeks=pattern.interval)
# Find next day of week
current_weekday = current_date.weekday()
next_weekday = min((d for d in pattern.days_of_week if d > current_weekday),
default=min(pattern.days_of_week))
@ -108,7 +100,6 @@ def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePatter
return current_date + timedelta(days=days_ahead)
elif pattern.type == 'monthly':
# Add months to current date
year = current_date.year + (current_date.month + pattern.interval - 1) // 12
month = (current_date.month + pattern.interval - 1) % 12 + 1
return current_date.replace(year=year, month=month)

View File

@ -1,60 +1,36 @@
# app/main.py
import logging
import uvicorn
from fastapi import FastAPI, HTTPException, Depends, status, Request
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from fastapi_users.authentication import JWTStrategy
from pydantic import BaseModel
from jose import jwt, JWTError
from sqlalchemy.ext.asyncio import AsyncEngine
from alembic.config import Config
from alembic import command
import os
import sys
from app.api.api_router import api_router
from app.config import settings
from app.core.api_config import API_METADATA, API_TAGS
from app.auth import fastapi_users, auth_backend, get_refresh_jwt_strategy, get_jwt_strategy
from app.models import User
from app.api.auth.oauth import router as oauth_router
from app.auth import fastapi_users, auth_backend
from app.schemas.user import UserPublic, UserCreate, UserUpdate
from app.core.scheduler import init_scheduler, shutdown_scheduler
from app.database import get_session
from sqlalchemy import select
# Response model for refresh endpoint
class RefreshResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
# Initialize Sentry only if DSN is provided
if settings.SENTRY_DSN:
sentry_sdk.init(
dsn=settings.SENTRY_DSN,
integrations=[
FastApiIntegration(),
],
# Adjust traces_sample_rate for production
traces_sample_rate=0.1 if settings.is_production else 1.0,
environment=settings.ENVIRONMENT,
# Enable PII data only in development
send_default_pii=not settings.is_production
)
# --- Logging Setup ---
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL),
format=settings.LOG_FORMAT
)
logger = logging.getLogger(__name__)
# --- FastAPI App Instance ---
# Create API metadata with environment-dependent settings
api_metadata = {
**API_METADATA,
"docs_url": settings.docs_url,
@ -67,13 +43,11 @@ app = FastAPI(
openapi_tags=API_TAGS
)
# Add session middleware for OAuth
app.add_middleware(
SessionMiddleware,
secret_key=settings.SESSION_SECRET_KEY
)
# --- CORS Middleware ---
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
@ -82,113 +56,9 @@ app.add_middleware(
allow_headers=["*"],
expose_headers=["*"]
)
# --- End CORS Middleware ---
# Refresh token endpoint
@app.post("/auth/jwt/refresh", response_model=RefreshResponse, tags=["auth"])
async def refresh_jwt_token(
request: Request,
refresh_strategy: JWTStrategy = Depends(get_refresh_jwt_strategy),
access_strategy: JWTStrategy = Depends(get_jwt_strategy),
):
"""
Refresh access token using a valid refresh token.
Send refresh token in Authorization header: Bearer <refresh_token>
"""
try:
# Get refresh token from Authorization header
authorization = request.headers.get("Authorization")
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token missing or invalid format",
headers={"WWW-Authenticate": "Bearer"},
)
refresh_token = authorization.split(" ")[1]
# Validate refresh token and get user data
try:
# Decode the refresh token to get the user identifier
payload = jwt.decode(refresh_token, settings.SECRET_KEY, algorithms=["HS256"])
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
# Get user from database
async with get_session() as session:
result = await session.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
# Generate new tokens
new_access_token = await access_strategy.write_token(user)
new_refresh_token = await refresh_strategy.write_token(user)
return RefreshResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="bearer"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error refreshing token: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# --- Include API Routers ---
# Include OAuth routes first (no auth required)
app.include_router(oauth_router, prefix="/auth", tags=["auth"])
# Include FastAPI-Users routes
app.include_router(
fastapi_users.get_auth_router(auth_backend),
prefix="/auth/jwt",
tags=["auth"],
)
app.include_router(
fastapi_users.get_register_router(UserPublic, UserCreate),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_verify_router(UserPublic),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_users_router(UserPublic, UserUpdate),
prefix="/users",
tags=["users"],
)
# Include your API router
app.include_router(api_router, prefix=settings.API_PREFIX)
# --- End Include API Routers ---
# Health check endpoint
@app.get("/health", tags=["Health"])
async def health_check():
"""
@ -200,7 +70,6 @@ async def health_check():
"version": settings.API_VERSION
}
# --- Root Endpoint (Optional - outside the main API structure) ---
@app.get("/", tags=["Root"])
async def read_root():
"""
@ -213,21 +82,17 @@ async def read_root():
"environment": settings.ENVIRONMENT,
"version": settings.API_VERSION
}
# --- End Root Endpoint ---
async def run_migrations():
"""Run database migrations."""
try:
logger.info("Running database migrations...")
# Get the absolute path to the alembic directory
base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
alembic_path = os.path.join(base_path, 'alembic')
# Add alembic directory to Python path
if alembic_path not in sys.path:
sys.path.insert(0, alembic_path)
# Import and run migrations
from migrations import run_migrations as run_db_migrations
await run_db_migrations()
@ -240,11 +105,7 @@ async def run_migrations():
async def startup_event():
"""Initialize services on startup."""
logger.info(f"Application startup in {settings.ENVIRONMENT} environment...")
# Run database migrations
# await run_migrations()
# Initialize scheduler
init_scheduler()
logger.info("Application startup complete.")
@ -252,15 +113,5 @@ async def startup_event():
async def shutdown_event():
"""Cleanup services on shutdown."""
logger.info("Application shutdown: Disconnecting from database...")
# await database.engine.dispose() # Close connection pool
shutdown_scheduler()
logger.info("Application shutdown complete.")
# --- End Events ---
# --- Direct Run (for simple local testing if needed) ---
# It's better to use `uvicorn app.main:app --reload` from the terminal
# if __name__ == "__main__":
# logger.info("Starting Uvicorn server directly from main.py")
# uvicorn.run(app, host="0.0.0.0", port=8000)
# ------------------------------------------------------

View File

@ -1,4 +1,3 @@
# app/models.py
import enum
import secrets
from datetime import datetime, timedelta, timezone
@ -14,16 +13,15 @@ from sqlalchemy import (
UniqueConstraint,
Index,
DDL,
event,
delete,
func,
text as sa_text,
Text, # <-- Add Text for description
Numeric, # <-- Add Numeric for price
Text,
Numeric,
CheckConstraint,
Date # Added Date for Chore model
Date
)
from sqlalchemy.orm import relationship, backref
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import JSONB
from .database import Base
@ -71,6 +69,19 @@ class ChoreTypeEnum(enum.Enum):
personal = "personal"
group = "group"
class ChoreHistoryEventTypeEnum(str, enum.Enum):
CREATED = "created"
UPDATED = "updated"
DELETED = "deleted"
COMPLETED = "completed"
REOPENED = "reopened"
ASSIGNED = "assigned"
UNASSIGNED = "unassigned"
REASSIGNED = "reassigned"
SCHEDULE_GENERATED = "schedule_generated"
DUE_DATE_CHANGED = "due_date_changed"
DETAILS_CHANGED = "details_changed"
# --- User Model ---
class User(Base):
__tablename__ = "users"
@ -82,35 +93,30 @@ class User(Base):
is_active = Column(Boolean, default=True, nullable=False)
is_superuser = Column(Boolean, default=False, nullable=False)
is_verified = Column(Boolean, default=False, nullable=False)
is_guest = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---
created_groups = relationship("Group", back_populates="creator")
group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan")
created_invites = relationship("Invite", back_populates="creator")
# --- NEW Relationships for Lists/Items ---
created_lists = relationship("List", foreign_keys="List.created_by_id", back_populates="creator") # Link List.created_by_id -> User
added_items = relationship("Item", foreign_keys="Item.added_by_id", back_populates="added_by_user") # Link Item.added_by_id -> User
completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user") # Link Item.completed_by_id -> User
# --- End NEW Relationships ---
# --- Relationships for Cost Splitting ---
created_lists = relationship("List", foreign_keys="List.created_by_id", back_populates="creator")
added_items = relationship("Item", foreign_keys="Item.added_by_id", back_populates="added_by_user")
completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user")
expenses_paid = relationship("Expense", foreign_keys="Expense.paid_by_user_id", back_populates="paid_by_user", cascade="all, delete-orphan")
expenses_created = relationship("Expense", foreign_keys="Expense.created_by_user_id", back_populates="created_by_user", cascade="all, delete-orphan")
expense_splits = relationship("ExpenseSplit", foreign_keys="ExpenseSplit.user_id", back_populates="user", cascade="all, delete-orphan")
settlements_made = relationship("Settlement", foreign_keys="Settlement.paid_by_user_id", back_populates="payer", cascade="all, delete-orphan")
settlements_received = relationship("Settlement", foreign_keys="Settlement.paid_to_user_id", back_populates="payee", cascade="all, delete-orphan")
settlements_created = relationship("Settlement", foreign_keys="Settlement.created_by_user_id", back_populates="created_by_user", cascade="all, delete-orphan")
# --- End Relationships for Cost Splitting ---
# --- Relationships for Chores ---
created_chores = relationship("Chore", foreign_keys="[Chore.created_by_id]", back_populates="creator")
assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan")
# --- End Relationships for Chores ---
chore_history_entries = relationship("ChoreHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
assignment_history_entries = relationship("ChoreAssignmentHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
financial_audit_logs = relationship("FinancialAuditLog", back_populates="user")
time_entries = relationship("TimeEntry", back_populates="user")
categories = relationship("Category", back_populates="user")
# --- Group Model ---
class Group(Base):
__tablename__ = "groups"
@ -118,27 +124,19 @@ class Group(Base):
name = Column(String, index=True, nullable=False)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1')
# --- Relationships ---
creator = relationship("User", back_populates="created_groups")
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan")
invites = relationship("Invite", back_populates="group", cascade="all, delete-orphan")
# --- NEW Relationship for Lists ---
lists = relationship("List", back_populates="group", cascade="all, delete-orphan") # Link List.group_id -> Group
# --- End NEW Relationship ---
# --- Relationships for Cost Splitting ---
lists = relationship("List", back_populates="group", cascade="all, delete-orphan")
expenses = relationship("Expense", foreign_keys="Expense.group_id", back_populates="group", cascade="all, delete-orphan")
settlements = relationship("Settlement", foreign_keys="Settlement.group_id", back_populates="group", cascade="all, delete-orphan")
# --- End Relationships for Cost Splitting ---
# --- Relationship for Chores ---
chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan")
# --- End Relationship for Chores ---
chore_history = relationship("ChoreHistory", back_populates="group", cascade="all, delete-orphan")
# --- UserGroup Association Model ---
class UserGroup(Base):
__tablename__ = "user_groups"
__table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),)
@ -152,8 +150,6 @@ class UserGroup(Base):
user = relationship("User", back_populates="group_associations")
group = relationship("Group", back_populates="member_associations")
# --- Invite Model ---
class Invite(Base):
__tablename__ = "invites"
__table_args__ = (
@ -172,36 +168,31 @@ class Invite(Base):
creator = relationship("User", back_populates="created_invites")
# === NEW: List Model ===
class List(Base):
__tablename__ = "lists"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who created this list
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) # Which group it belongs to (NULL if personal)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True)
is_complete = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1')
archived_at = Column(DateTime(timezone=True), nullable=True, index=True)
# --- Relationships ---
creator = relationship("User", back_populates="created_lists") # Link to User.created_lists
group = relationship("Group", back_populates="lists") # Link to Group.lists
creator = relationship("User", back_populates="created_lists")
group = relationship("Group", back_populates="lists")
items = relationship(
"Item",
back_populates="list",
cascade="all, delete-orphan",
order_by="Item.position.asc(), Item.created_at.asc()" # Default order by position, then creation
order_by="Item.position.asc(), Item.created_at.asc()"
)
# --- Relationships for Cost Splitting ---
expenses = relationship("Expense", foreign_keys="Expense.list_id", back_populates="list", cascade="all, delete-orphan")
# --- End Relationships for Cost Splitting ---
# === NEW: Item Model ===
class Item(Base):
__tablename__ = "items"
__table_args__ = (
@ -209,30 +200,25 @@ class Item(Base):
)
id = Column(Integer, primary_key=True, index=True)
list_id = Column(Integer, ForeignKey("lists.id", ondelete="CASCADE"), nullable=False) # Belongs to which list
list_id = Column(Integer, ForeignKey("lists.id", ondelete="CASCADE"), nullable=False)
name = Column(String, index=True, nullable=False)
quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch")
quantity = Column(String, nullable=True)
is_complete = Column(Boolean, default=False, nullable=False)
price = Column(Numeric(10, 2), nullable=True) # For cost splitting later (e.g., 12345678.99)
position = Column(Integer, nullable=False, server_default='0') # For ordering
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who added this item
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Who marked it complete
price = Column(Numeric(10, 2), nullable=True)
position = Column(Integer, nullable=False, server_default='0')
category_id = Column(Integer, ForeignKey('categories.id'), nullable=True)
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1')
# --- Relationships ---
list = relationship("List", back_populates="items") # Link to List.items
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items") # Link to User.added_items
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items") # Link to User.completed_items
# --- Relationships for Cost Splitting ---
# If an item directly results in an expense, or an expense can be tied to an item.
expenses = relationship("Expense", back_populates="item") # An item might have multiple associated expenses
# --- End Relationships for Cost Splitting ---
# === NEW Models for Advanced Cost Splitting ===
list = relationship("List", back_populates="items")
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items")
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items")
expenses = relationship("Expense", back_populates="item")
category = relationship("Category", back_populates="items")
class Expense(Base):
__tablename__ = "expenses"
@ -244,7 +230,6 @@ class Expense(Base):
expense_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
split_type = Column(SAEnum(SplitTypeEnum, name="splittypeenum", create_type=True), nullable=False)
# Foreign Keys
list_id = Column(Integer, ForeignKey("lists.id"), nullable=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True, index=True)
item_id = Column(Integer, ForeignKey("items.id"), nullable=True)
@ -255,7 +240,6 @@ class Expense(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1')
# Relationships
paid_by_user = relationship("User", foreign_keys=[paid_by_user_id], back_populates="expenses_paid")
created_by_user = relationship("User", foreign_keys=[created_by_user_id], back_populates="expenses_created")
list = relationship("List", foreign_keys=[list_id], back_populates="expenses")
@ -265,7 +249,6 @@ class Expense(Base):
parent_expense = relationship("Expense", remote_side=[id], back_populates="child_expenses")
child_expenses = relationship("Expense", back_populates="parent_expense")
overall_settlement_status = Column(SAEnum(ExpenseOverallStatusEnum, name="expenseoverallstatusenum", create_type=True), nullable=False, server_default=ExpenseOverallStatusEnum.unpaid.value, default=ExpenseOverallStatusEnum.unpaid)
# --- Recurrence fields ---
is_recurring = Column(Boolean, default=False, nullable=False)
recurrence_pattern_id = Column(Integer, ForeignKey("recurrence_patterns.id"), nullable=True)
recurrence_pattern = relationship("RecurrencePattern", back_populates="expenses", uselist=False) # One-to-one
@ -274,8 +257,7 @@ class Expense(Base):
last_occurrence = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
# Ensure at least one context is provided
CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'),
CheckConstraint('group_id IS NOT NULL OR list_id IS NOT NULL', name='ck_expense_group_or_list'),
)
class ExpenseSplit(Base):
@ -296,14 +278,12 @@ class ExpenseSplit(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
expense = relationship("Expense", back_populates="splits")
user = relationship("User", foreign_keys=[user_id], back_populates="expense_splits")
settlement_activities = relationship("SettlementActivity", back_populates="split", cascade="all, delete-orphan")
# New fields for tracking payment status
status = Column(SAEnum(ExpenseSplitStatusEnum, name="expensesplitstatusenum", create_type=True), nullable=False, server_default=ExpenseSplitStatusEnum.unpaid.value, default=ExpenseSplitStatusEnum.unpaid)
paid_at = Column(DateTime(timezone=True), nullable=True) # Timestamp when the split was fully paid
paid_at = Column(DateTime(timezone=True), nullable=True)
class Settlement(Base):
__tablename__ = "settlements"
@ -321,33 +301,28 @@ class Settlement(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1')
# Relationships
group = relationship("Group", foreign_keys=[group_id], back_populates="settlements")
payer = relationship("User", foreign_keys=[paid_by_user_id], back_populates="settlements_made")
payee = relationship("User", foreign_keys=[paid_to_user_id], back_populates="settlements_received")
created_by_user = relationship("User", foreign_keys=[created_by_user_id], back_populates="settlements_created")
__table_args__ = (
# Ensure payer and payee are different users
CheckConstraint('paid_by_user_id != paid_to_user_id', name='chk_settlement_different_users'),
)
# Potential future: PaymentMethod model, etc.
class SettlementActivity(Base):
__tablename__ = "settlement_activities"
id = Column(Integer, primary_key=True, index=True)
expense_split_id = Column(Integer, ForeignKey("expense_splits.id"), nullable=False, index=True)
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) # User who made this part of the payment
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
paid_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
amount_paid = Column(Numeric(10, 2), nullable=False)
created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) # User who recorded this activity
created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# --- Relationships ---
split = relationship("ExpenseSplit", back_populates="settlement_activities")
payer = relationship("User", foreign_keys=[paid_by_user_id], backref="made_settlement_activities")
creator = relationship("User", foreign_keys=[created_by_user_id], backref="created_settlement_activities")
@ -369,20 +344,23 @@ class Chore(Base):
name = Column(String, nullable=False, index=True)
description = Column(Text, nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
parent_chore_id = Column(Integer, ForeignKey('chores.id'), nullable=True, index=True)
frequency = Column(SAEnum(ChoreFrequencyEnum, name="chorefrequencyenum", create_type=True), nullable=False)
custom_interval_days = Column(Integer, nullable=True) # Only if frequency is 'custom'
custom_interval_days = Column(Integer, nullable=True)
next_due_date = Column(Date, nullable=False) # Changed to Date
next_due_date = Column(Date, nullable=False)
last_completed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# --- Relationships ---
group = relationship("Group", back_populates="chores")
creator = relationship("User", back_populates="created_chores")
assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan")
history = relationship("ChoreHistory", back_populates="chore", cascade="all, delete-orphan")
parent_chore = relationship("Chore", remote_side=[id], back_populates="child_chores")
child_chores = relationship("Chore", back_populates="parent_chore", cascade="all, delete-orphan")
# --- ChoreAssignment Model ---
@ -393,16 +371,17 @@ class ChoreAssignment(Base):
chore_id = Column(Integer, ForeignKey("chores.id", ondelete="CASCADE"), nullable=False, index=True)
assigned_to_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
due_date = Column(Date, nullable=False) # Specific due date for this instance, changed to Date
due_date = Column(Date, nullable=False)
is_complete = Column(Boolean, default=False, nullable=False)
completed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# --- Relationships ---
chore = relationship("Chore", back_populates="assignments")
assigned_user = relationship("User", back_populates="assigned_chores")
history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan")
time_entries = relationship("TimeEntry", back_populates="assignment", cascade="all, delete-orphan")
# === NEW: RecurrencePattern Model ===
@ -411,22 +390,83 @@ class RecurrencePattern(Base):
id = Column(Integer, primary_key=True, index=True)
type = Column(SAEnum(RecurrenceTypeEnum, name="recurrencetypeenum", create_type=True), nullable=False)
interval = Column(Integer, default=1, nullable=False) # e.g., every 1 day, every 2 weeks
days_of_week = Column(String, nullable=True) # For weekly recurrences, e.g., "MON,TUE,FRI"
# day_of_month = Column(Integer, nullable=True) # For monthly on a specific day
# week_of_month = Column(Integer, nullable=True) # For monthly on a specific week (e.g., 2nd week)
# month_of_year = Column(Integer, nullable=True) # For yearly recurrences
interval = Column(Integer, default=1, nullable=False)
days_of_week = Column(String, nullable=True)
end_date = Column(DateTime(timezone=True), nullable=True)
max_occurrences = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationship back to Expenses that use this pattern (could be one-to-many if patterns are shared)
# However, the current CRUD implies one RecurrencePattern per Expense if recurring.
# If a pattern can be shared, this would be a one-to-many (RecurrencePattern to many Expenses).
# For now, assuming one-to-one as implied by current Expense.recurrence_pattern relationship setup.
expenses = relationship("Expense", back_populates="recurrence_pattern")
# === END: RecurrencePattern Model ===
# === NEW: Chore History Models ===
class ChoreHistory(Base):
__tablename__ = "chore_history"
id = Column(Integer, primary_key=True, index=True)
chore_id = Column(Integer, ForeignKey("chores.id", ondelete="CASCADE"), nullable=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True)
event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False)
event_data = Column(JSONB, nullable=True)
changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
chore = relationship("Chore", back_populates="history")
group = relationship("Group", back_populates="chore_history")
changed_by_user = relationship("User", back_populates="chore_history_entries")
class ChoreAssignmentHistory(Base):
__tablename__ = "chore_assignment_history"
id = Column(Integer, primary_key=True, index=True)
assignment_id = Column(Integer, ForeignKey("chore_assignments.id", ondelete="CASCADE"), nullable=False, index=True)
event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False)
event_data = Column(JSONB, nullable=True)
changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
assignment = relationship("ChoreAssignment", back_populates="history")
changed_by_user = relationship("User", back_populates="assignment_history_entries")
# --- New Models from Roadmap ---
class FinancialAuditLog(Base):
__tablename__ = 'financial_audit_log'
id = Column(Integer, primary_key=True, index=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=True)
action_type = Column(String, nullable=False, index=True)
entity_type = Column(String, nullable=False)
entity_id = Column(Integer, nullable=False)
details = Column(JSONB, nullable=True)
user = relationship("User", back_populates="financial_audit_logs")
class Category(Base):
__tablename__ = 'categories'
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=True)
group_id = Column(Integer, ForeignKey('groups.id'), nullable=True)
user = relationship("User", back_populates="categories")
items = relationship("Item", back_populates="category")
__table_args__ = (UniqueConstraint('name', 'user_id', 'group_id', name='uq_category_scope'),)
class TimeEntry(Base):
__tablename__ = 'time_entries'
id = Column(Integer, primary_key=True, index=True)
chore_assignment_id = Column(Integer, ForeignKey('chore_assignments.id', ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False)
end_time = Column(DateTime(timezone=True), nullable=True)
duration_seconds = Column(Integer, nullable=True)
assignment = relationship("ChoreAssignment", back_populates="time_entries")
user = relationship("User", back_populates="time_entries")

20
be/app/schemas/audit.py Normal file
View File

@ -0,0 +1,20 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional, Dict, Any
class FinancialAuditLogBase(BaseModel):
action_type: str
entity_type: str
entity_id: int
details: Optional[Dict[str, Any]] = None
class FinancialAuditLogCreate(FinancialAuditLogBase):
user_id: Optional[int] = None
class FinancialAuditLogPublic(FinancialAuditLogBase):
id: int
timestamp: datetime
user_id: Optional[int] = None
class Config:
orm_mode = True

View File

@ -1,13 +1,7 @@
# app/schemas/auth.py
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel
from app.config import settings
class Token(BaseModel):
access_token: str
refresh_token: str # Added refresh token
token_type: str = settings.TOKEN_TYPE # Use configured token type
# Optional: If you preferred not to use OAuth2PasswordRequestForm
# class UserLogin(BaseModel):
# email: EmailStr
# password: str
refresh_token: str
token_type: str = settings.TOKEN_TYPE

View File

@ -0,0 +1,19 @@
from pydantic import BaseModel
from typing import Optional
class CategoryBase(BaseModel):
name: str
class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(CategoryBase):
pass
class CategoryPublic(CategoryBase):
id: int
user_id: Optional[int] = None
group_id: Optional[int] = None
class Config:
orm_mode = True

View File

@ -1,14 +1,32 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Optional, List
from typing import Optional, List, Any
from pydantic import BaseModel, ConfigDict, field_validator
from ..models import ChoreFrequencyEnum, ChoreTypeEnum, ChoreHistoryEventTypeEnum
from .user import UserPublic
class ChoreAssignmentPublic(BaseModel):
pass
class ChoreHistoryPublic(BaseModel):
id: int
event_type: ChoreHistoryEventTypeEnum
event_data: Optional[dict[str, Any]] = None
changed_by_user: Optional[UserPublic] = None
timestamp: datetime
model_config = ConfigDict(from_attributes=True)
class ChoreAssignmentHistoryPublic(BaseModel):
id: int
event_type: ChoreHistoryEventTypeEnum
event_data: Optional[dict[str, Any]] = None
changed_by_user: Optional[UserPublic] = None
timestamp: datetime
model_config = ConfigDict(from_attributes=True)
# Assuming ChoreFrequencyEnum is imported from models
# Adjust the import path if necessary based on your project structure.
# e.g., from app.models import ChoreFrequencyEnum
from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel # For UserPublic relation
from .user import UserPublic # For embedding user information
# Chore Schemas
class ChoreBase(BaseModel):
name: str
description: Optional[str] = None
@ -38,6 +56,7 @@ class ChoreBase(BaseModel):
class ChoreCreate(ChoreBase):
group_id: Optional[int] = None
parent_chore_id: Optional[int] = None
@field_validator('group_id')
@classmethod
@ -72,10 +91,13 @@ class ChorePublic(ChoreBase):
group_id: Optional[int] = None
created_by_id: int
last_completed_at: Optional[datetime] = None
parent_chore_id: Optional[int] = None
created_at: datetime
updated_at: datetime
creator: Optional[UserPublic] = None # Embed creator UserPublic schema
# group: Optional[GroupPublic] = None # Embed GroupPublic schema if needed
assignments: List[ChoreAssignmentPublic] = []
history: List[ChoreHistoryPublic] = []
child_chores: List[ChorePublic] = []
model_config = ConfigDict(from_attributes=True)
@ -92,6 +114,7 @@ class ChoreAssignmentUpdate(BaseModel):
# Only completion status and perhaps due_date can be updated for an assignment
is_complete: Optional[bool] = None
due_date: Optional[date] = None # If rescheduling an existing assignment is allowed
assigned_to_user_id: Optional[int] = None # For reassigning the chore
class ChoreAssignmentPublic(ChoreAssignmentBase):
id: int
@ -102,10 +125,11 @@ class ChoreAssignmentPublic(ChoreAssignmentBase):
# Embed ChorePublic and UserPublic for richer responses
chore: Optional[ChorePublic] = None
assigned_user: Optional[UserPublic] = None
history: List[ChoreAssignmentHistoryPublic] = []
model_config = ConfigDict(from_attributes=True)
# To handle potential circular imports if ChorePublic needs GroupPublic and GroupPublic needs ChorePublic
# We can update forward refs after all models are defined.
# ChorePublic.model_rebuild() # If using Pydantic v2 and forward refs were used with strings
# ChoreAssignmentPublic.model_rebuild()
ChorePublic.model_rebuild()
ChoreAssignmentPublic.model_rebuild()

View File

@ -4,10 +4,10 @@ from decimal import Decimal
class UserCostShare(BaseModel):
user_id: int
user_identifier: str # Name or email
items_added_value: Decimal = Decimal("0.00") # Total value of items this user added
amount_due: Decimal # The user's share of the total cost (for equal split, this is total_cost / num_users)
balance: Decimal # items_added_value - amount_due
user_identifier: str
items_added_value: Decimal = Decimal("0.00")
amount_due: Decimal
balance: Decimal
model_config = ConfigDict(from_attributes=True)
@ -23,19 +23,19 @@ class ListCostSummary(BaseModel):
class UserBalanceDetail(BaseModel):
user_id: int
user_identifier: str # Name or email
user_identifier: str
total_paid_for_expenses: Decimal = Decimal("0.00")
total_share_of_expenses: Decimal = Decimal("0.00")
total_settlements_paid: Decimal = Decimal("0.00")
total_settlements_received: Decimal = Decimal("0.00")
net_balance: Decimal = Decimal("0.00") # (paid_for_expenses + settlements_received) - (share_of_expenses + settlements_paid)
net_balance: Decimal = Decimal("0.00")
model_config = ConfigDict(from_attributes=True)
class SuggestedSettlement(BaseModel):
from_user_id: int
from_user_identifier: str # Name or email of payer
from_user_identifier: str
to_user_id: int
to_user_identifier: str # Name or email of payee
to_user_identifier: str
amount: Decimal
model_config = ConfigDict(from_attributes=True)
@ -45,11 +45,5 @@ class GroupBalanceSummary(BaseModel):
overall_total_expenses: Decimal = Decimal("0.00")
overall_total_settlements: Decimal = Decimal("0.00")
user_balances: List[UserBalanceDetail]
# Optional: Could add a list of suggested settlements to zero out balances
suggested_settlements: Optional[List[SuggestedSettlement]] = None
model_config = ConfigDict(from_attributes=True)
# class SuggestedSettlement(BaseModel):
# from_user_id: int
# to_user_id: int
# amount: Decimal

View File

@ -1,19 +1,11 @@
# app/schemas/expense.py
from pydantic import BaseModel, ConfigDict, validator, Field
from typing import List, Optional, Dict, Any
from typing import List, Optional
from decimal import Decimal
from datetime import datetime
from app.models import SplitTypeEnum, ExpenseSplitStatusEnum, ExpenseOverallStatusEnum
from app.schemas.user import UserPublic
from app.schemas.settlement_activity import SettlementActivityPublic
# Assuming SplitTypeEnum is accessible here, e.g., from app.models or app.core.enums
# For now, let's redefine it or import it if models.py is parsable by Pydantic directly
# If it's from app.models, you might need to make app.models.SplitTypeEnum Pydantic-compatible or map it.
# For simplicity during schema definition, I'll redefine a string enum here.
# In a real setup, ensure this aligns with the SQLAlchemy enum in models.py.
from app.models import SplitTypeEnum, ExpenseSplitStatusEnum, ExpenseOverallStatusEnum # Try importing directly
from app.schemas.user import UserPublic # For user details in responses
from app.schemas.settlement_activity import SettlementActivityPublic # For settlement activities
# --- ExpenseSplit Schemas ---
class ExpenseSplitBase(BaseModel):
user_id: int
owed_amount: Decimal
@ -21,20 +13,19 @@ class ExpenseSplitBase(BaseModel):
share_units: Optional[int] = None
class ExpenseSplitCreate(ExpenseSplitBase):
pass # All fields from base are needed for creation
pass
class ExpenseSplitPublic(ExpenseSplitBase):
id: int
expense_id: int
user: Optional[UserPublic] = None # If we want to nest user details
user: Optional[UserPublic] = None
created_at: datetime
updated_at: datetime
status: ExpenseSplitStatusEnum # New field
paid_at: Optional[datetime] = None # New field
settlement_activities: List[SettlementActivityPublic] = [] # New field
status: ExpenseSplitStatusEnum
paid_at: Optional[datetime] = None
settlement_activities: List[SettlementActivityPublic] = []
model_config = ConfigDict(from_attributes=True)
# --- Expense Schemas ---
class RecurrencePatternBase(BaseModel):
type: str = Field(..., description="Type of recurrence: daily, weekly, monthly, yearly")
interval: int = Field(..., description="Interval of recurrence (e.g., every X days/weeks/months/years)")
@ -63,16 +54,13 @@ class ExpenseBase(BaseModel):
expense_date: Optional[datetime] = None
split_type: SplitTypeEnum
list_id: Optional[int] = None
group_id: Optional[int] = None # Should be present if list_id is not, and vice-versa
group_id: Optional[int] = None
item_id: Optional[int] = None
paid_by_user_id: int
is_recurring: bool = Field(False, description="Whether this is a recurring expense")
recurrence_pattern: Optional[RecurrencePatternCreate] = Field(None, description="Recurrence pattern for recurring expenses")
class ExpenseCreate(ExpenseBase):
# For EQUAL split, splits are generated. For others, they might be provided.
# This logic will be in the CRUD: if split_type is EXACT_AMOUNTS, PERCENTAGE, SHARES,
# then 'splits_in' should be provided.
splits_in: Optional[List[ExpenseSplitCreate]] = None
@validator('total_amount')
@ -81,8 +69,6 @@ class ExpenseCreate(ExpenseBase):
raise ValueError('Total amount must be positive')
return v
# Basic validation: if list_id is None, group_id must be provided.
# More complex cross-field validation might be needed.
@validator('group_id', always=True)
def check_list_or_group_id(cls, v, values):
if values.get('list_id') is None and v is None:
@ -106,9 +92,7 @@ class ExpenseUpdate(BaseModel):
list_id: Optional[int] = None
group_id: Optional[int] = None
item_id: Optional[int] = None
# paid_by_user_id is usually not updatable directly to maintain integrity.
# Updating splits would be a more complex operation, potentially a separate endpoint or careful logic.
version: int # For optimistic locking
version: int
is_recurring: Optional[bool] = None
recurrence_pattern: Optional[RecurrencePatternUpdate] = None
next_occurrence: Optional[datetime] = None
@ -120,11 +104,8 @@ class ExpensePublic(ExpenseBase):
version: int
created_by_user_id: int
splits: List[ExpenseSplitPublic] = []
paid_by_user: Optional[UserPublic] = None # If nesting user details
overall_settlement_status: ExpenseOverallStatusEnum # New field
# list: Optional[ListPublic] # If nesting list details
# group: Optional[GroupPublic] # If nesting group details
# item: Optional[ItemPublic] # If nesting item details
paid_by_user: Optional[UserPublic] = None
overall_settlement_status: ExpenseOverallStatusEnum
is_recurring: bool
next_occurrence: Optional[datetime]
last_occurrence: Optional[datetime]
@ -133,7 +114,6 @@ class ExpensePublic(ExpenseBase):
generated_expenses: List['ExpensePublic'] = []
model_config = ConfigDict(from_attributes=True)
# --- Settlement Schemas ---
class SettlementBase(BaseModel):
group_id: int
paid_by_user_id: int
@ -159,8 +139,7 @@ class SettlementUpdate(BaseModel):
amount: Optional[Decimal] = None
settlement_date: Optional[datetime] = None
description: Optional[str] = None
# group_id, paid_by_user_id, paid_to_user_id are typically not updatable.
version: int # For optimistic locking
version: int
class SettlementPublic(SettlementBase):
id: int
@ -168,13 +147,4 @@ class SettlementPublic(SettlementBase):
updated_at: datetime
version: int
created_by_user_id: int
# payer: Optional[UserPublic] # If we want to include payer details
# payee: Optional[UserPublic] # If we want to include payee details
# group: Optional[GroupPublic] # If we want to include group details
model_config = ConfigDict(from_attributes=True)
# Placeholder for nested schemas (e.g., UserPublic) if needed
# from app.schemas.user import UserPublic
# from app.schemas.list import ListPublic
# from app.schemas.group import GroupPublic
# from app.schemas.item import ItemPublic

View File

@ -1,21 +1,24 @@
# app/schemas/group.py
from pydantic import BaseModel, ConfigDict, computed_field
from datetime import datetime
from datetime import datetime, date
from typing import Optional, List
from .user import UserPublic
from .chore import ChoreHistoryPublic
from .user import UserPublic # Import UserPublic to represent members
# Properties to receive via API on creation
class GroupCreate(BaseModel):
name: str
# Properties to return to client
class GroupScheduleGenerateRequest(BaseModel):
start_date: date
end_date: date
member_ids: Optional[List[int]] = None
class GroupPublic(BaseModel):
id: int
name: str
created_by_id: int
created_at: datetime
member_associations: Optional[List["UserGroupPublic"]] = None
chore_history: Optional[List[ChoreHistoryPublic]] = []
@computed_field
@property
@ -26,7 +29,6 @@ class GroupPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
# Properties for UserGroup association
class UserGroupPublic(BaseModel):
id: int
user_id: int
@ -37,6 +39,4 @@ class UserGroupPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
# Properties stored in DB (if needed, often GroupPublic is sufficient)
# class GroupInDB(GroupPublic):
# pass
GroupPublic.model_rebuild()

View File

@ -1,4 +1,4 @@
# app/schemas/health.py
from pydantic import BaseModel
from app.config import settings
@ -6,5 +6,5 @@ class HealthStatus(BaseModel):
"""
Response model for the health check endpoint.
"""
status: str = settings.HEALTH_STATUS_OK # Use configured default value
status: str = settings.HEALTH_STATUS_OK
database: str

View File

@ -1,12 +1,9 @@
# app/schemas/invite.py
from pydantic import BaseModel
from datetime import datetime
# Properties to receive when accepting an invite
class InviteAccept(BaseModel):
code: str
# Properties to return when an invite is created
class InviteCodePublic(BaseModel):
code: str
expires_at: datetime

View File

@ -1,10 +1,13 @@
# app/schemas/item.py
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional
from decimal import Decimal
# Properties to return to client
class UserReference(BaseModel):
id: int
name: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class ItemPublic(BaseModel):
id: int
list_id: int
@ -12,26 +15,26 @@ class ItemPublic(BaseModel):
quantity: Optional[str] = None
is_complete: bool
price: Optional[Decimal] = None
category_id: Optional[int] = None
added_by_id: int
completed_by_id: Optional[int] = None
added_by_user: Optional[UserReference] = None
completed_by_user: Optional[UserReference] = None
created_at: datetime
updated_at: datetime
version: int
model_config = ConfigDict(from_attributes=True)
# Properties to receive via API on creation
class ItemCreate(BaseModel):
name: str
quantity: Optional[str] = None
# list_id will be from path param
# added_by_id will be from current_user
category_id: Optional[int] = None
# Properties to receive via API on update
class ItemUpdate(BaseModel):
name: Optional[str] = None
quantity: Optional[str] = None
is_complete: Optional[bool] = None
price: Optional[Decimal] = None # Price added here for update
position: Optional[int] = None # For reordering
price: Optional[Decimal] = None
position: Optional[int] = None
category_id: Optional[int] = None
version: int
# completed_by_id will be set internally if is_complete is true

View File

@ -1,25 +1,20 @@
# app/schemas/list.py
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional, List
from .item import ItemPublic # Import item schema for nesting
from .item import ItemPublic
# Properties to receive via API on creation
class ListCreate(BaseModel):
name: str
description: Optional[str] = None
group_id: Optional[int] = None # Optional for sharing
group_id: Optional[int] = None
# Properties to receive via API on update
class ListUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
is_complete: Optional[bool] = None
version: int # Client must provide the version for updates
# Potentially add group_id update later if needed
version: int
# Base properties returned by API (common fields)
class ListBase(BaseModel):
id: int
name: str
@ -29,17 +24,15 @@ class ListBase(BaseModel):
is_complete: bool
created_at: datetime
updated_at: datetime
version: int # Include version in responses
version: int
model_config = ConfigDict(from_attributes=True)
# Properties returned when listing lists (no items)
class ListPublic(ListBase):
pass # Inherits all from ListBase
pass
# Properties returned for a single list detail (includes items)
class ListDetail(ListBase):
items: List[ItemPublic] = [] # Include list of items
items: List[ItemPublic] = []
class ListStatus(BaseModel):
updated_at: datetime

View File

@ -1,4 +1,3 @@
# app/schemas/message.py
from pydantic import BaseModel
class Message(BaseModel):

View File

@ -1,6 +1,5 @@
# app/schemas/ocr.py
from pydantic import BaseModel
from typing import List
class OcrExtractResponse(BaseModel):
extracted_items: List[str] # A list of potential item names
extracted_items: List[str]

View File

@ -3,7 +3,7 @@ from typing import Optional, List
from decimal import Decimal
from datetime import datetime
from app.schemas.user import UserPublic # Assuming UserPublic is defined here
from app.schemas.user import UserPublic
class SettlementActivityBase(BaseModel):
expense_split_id: int
@ -21,23 +21,13 @@ class SettlementActivityCreate(SettlementActivityBase):
class SettlementActivityPublic(SettlementActivityBase):
id: int
created_by_user_id: int # User who recorded this activity
created_by_user_id: int
created_at: datetime
updated_at: datetime
payer: Optional[UserPublic] = None # User who made this part of the payment
creator: Optional[UserPublic] = None # User who recorded this activity
payer: Optional[UserPublic] = None
creator: Optional[UserPublic] = None
model_config = ConfigDict(from_attributes=True)
# Schema for updating a settlement activity (if needed in the future)
# class SettlementActivityUpdate(BaseModel):
# amount_paid: Optional[Decimal] = None
# paid_at: Optional[datetime] = None
# @field_validator('amount_paid')
# @classmethod
# def amount_must_be_positive_if_provided(cls, v: Optional[Decimal]) -> Optional[Decimal]:
# if v is not None and v <= Decimal("0"):
# raise ValueError("Amount paid must be a positive value.")
# return v

View File

@ -0,0 +1,22 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
class TimeEntryBase(BaseModel):
chore_assignment_id: int
start_time: datetime
end_time: Optional[datetime] = None
duration_seconds: Optional[int] = None
class TimeEntryCreate(TimeEntryBase):
pass
class TimeEntryUpdate(BaseModel):
end_time: datetime
class TimeEntryPublic(TimeEntryBase):
id: int
user_id: int
class Config:
orm_mode = True

8
be/app/schemas/token.py Normal file
View File

@ -0,0 +1,8 @@
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: str | None = None

View File

@ -1,14 +1,11 @@
# app/schemas/user.py
from pydantic import BaseModel, EmailStr, ConfigDict
from datetime import datetime
from typing import Optional
# Shared properties
class UserBase(BaseModel):
email: EmailStr
name: Optional[str] = None
# Properties to receive via API on creation
class UserCreate(UserBase):
password: str
@ -22,26 +19,26 @@ class UserCreate(UserBase):
"is_verified": False
}
# Properties to receive via API on update
class UserUpdate(UserBase):
password: Optional[str] = None
is_active: Optional[bool] = None
is_superuser: Optional[bool] = None
is_verified: Optional[bool] = None
# Properties stored in DB
class UserClaim(BaseModel):
email: EmailStr
password: str
class UserInDBBase(UserBase):
id: int
password_hash: str
created_at: datetime
model_config = ConfigDict(from_attributes=True) # Use orm_mode in Pydantic v1
model_config = ConfigDict(from_attributes=True)
# Additional properties to return via API (excluding password)
class UserPublic(UserBase):
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# Full user model including hashed password (for internal use/reading from DB)
class User(UserInDBBase):
pass

View File

@ -25,3 +25,4 @@ aiosqlite>=0.19.0 # For async SQLite support in tests
# Scheduler
APScheduler==3.10.4
redis>=5.0.0

29
fe/package-lock.json generated
View File

@ -15,6 +15,7 @@
"@vueuse/core": "^13.1.0",
"axios": "^1.9.0",
"date-fns": "^4.1.0",
"framer-motion": "^12.16.0",
"motion": "^12.15.0",
"pinia": "^3.0.2",
"qs": "^6.14.0",
@ -34,6 +35,7 @@
"@types/date-fns": "^2.5.3",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.15.17",
"@types/qs": "^6.14.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vitest/eslint-plugin": "^1.1.39",
"@vue/eslint-config-prettier": "^10.2.0",
@ -4291,6 +4293,13 @@
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.6",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz",
@ -7491,12 +7500,12 @@
}
},
"node_modules/framer-motion": {
"version": "12.15.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.15.0.tgz",
"integrity": "sha512-XKg/LnKExdLGugZrDILV7jZjI599785lDIJZLxMiiIFidCsy0a4R2ZEf+Izm67zyOuJgQYTHOmodi7igQsw3vg==",
"version": "12.16.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.16.0.tgz",
"integrity": "sha512-xryrmD4jSBQrS2IkMdcTmiS4aSKckbS7kLDCuhUn9110SQKG1w3zlq1RTqCblewg+ZYe+m3sdtzQA6cRwo5g8Q==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.15.0",
"motion-dom": "^12.16.0",
"motion-utils": "^12.12.1",
"tslib": "^2.4.0"
},
@ -9295,9 +9304,9 @@
}
},
"node_modules/motion-dom": {
"version": "12.15.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.15.0.tgz",
"integrity": "sha512-D2ldJgor+2vdcrDtKJw48k3OddXiZN1dDLLWrS8kiHzQdYVruh0IoTwbJBslrnTXIPgFED7PBN2Zbwl7rNqnhA==",
"version": "12.16.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.16.0.tgz",
"integrity": "sha512-Z2nGwWrrdH4egLEtgYMCEN4V2qQt1qxlKy/uV7w691ztyA41Q5Rbn0KNGbsNVDZr9E8PD2IOQ3hSccRnB6xWzw==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.12.1"
@ -11732,9 +11741,9 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@ -26,6 +26,7 @@
"@vueuse/core": "^13.1.0",
"axios": "^1.9.0",
"date-fns": "^4.1.0",
"framer-motion": "^12.16.0",
"motion": "^12.15.0",
"pinia": "^3.0.2",
"qs": "^6.14.0",
@ -45,6 +46,7 @@
"@types/date-fns": "^2.5.3",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.15.17",
"@types/qs": "^6.14.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vitest/eslint-plugin": "^1.1.39",
"@vue/eslint-config-prettier": "^10.2.0",

View File

@ -81,7 +81,8 @@
body {
font-family: 'Patrick Hand', cursive;
background-color: var(--light);
background-image: var(--paper-texture);
// background-image: var(--paper-texture);
// background-image: url('@/assets/11.webp');
// padding: 2rem 1rem;s
color: var(--dark);
font-size: 1.1rem;
@ -917,11 +918,13 @@ select.form-input {
.modal-backdrop {
position: fixed;
inset: 0;
background-color: rgba(57, 62, 70, 0.7);
background-color: rgba(57, 62, 70, 0.9);
/* Increased opacity for better visibility */
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: 9999;
/* Increased z-index to ensure it's above other elements */
opacity: 0;
visibility: hidden;
transition:
@ -941,16 +944,18 @@ select.form-input {
background-color: var(--light);
border: var(--border);
width: 90%;
max-width: 550px;
max-width: 850px;
box-shadow: var(--shadow-lg);
position: relative;
overflow-y: scroll;
/* Can cause tooltip clipping */
overflow-y: auto;
/* Changed from scroll to auto */
transform: scale(0.95) translateY(-20px);
transition: transform var(--transition-speed) var(--transition-ease-out);
max-height: 90vh;
display: flex;
flex-direction: column;
z-index: 10000;
/* Ensure modal content is above backdrop */
}
.modal-container::before {

View File

@ -0,0 +1,47 @@
<template>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="category-name">Category Name</label>
<input type="text" id="category-name" v-model="categoryName" required />
</div>
<div class="form-actions">
<button type="submit" :disabled="loading">
{{ isEditing ? 'Update' : 'Create' }}
</button>
<button type="button" @click="emit('cancel')" :disabled="loading">
Cancel
</button>
</div>
</form>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, onMounted, computed } from 'vue';
import type { Category } from '../stores/categoryStore';
const props = defineProps<{
category?: Category | null;
loading: boolean;
}>();
const emit = defineEmits<{
(e: 'submit', data: { name: string }): void;
(e: 'cancel'): void;
}>();
const categoryName = ref('');
const isEditing = computed(() => !!props.category);
onMounted(() => {
if (props.category) {
categoryName.value = props.category.name;
}
});
const handleSubmit = () => {
if (categoryName.value.trim()) {
emit('submit', { name: categoryName.value.trim() });
}
};
</script>

View File

@ -0,0 +1,355 @@
<template>
<li class="neo-list-item" :class="`status-${getDueDateStatus(chore)}`">
<div class="neo-item-content">
<label class="neo-checkbox-label">
<input type="checkbox" :checked="chore.is_completed" @change="emit('toggle-completion', chore)">
<div class="checkbox-content">
<div class="chore-main-info">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
{{ chore.name }}
</span>
<div class="chore-badges">
<span v-if="chore.type === 'group'" class="badge badge-group">Group</span>
<span v-if="getDueDateStatus(chore) === 'overdue'"
class="badge badge-overdue">Overdue</span>
<span v-if="getDueDateStatus(chore) === 'due-today'" class="badge badge-due-today">Due
Today</span>
</div>
</div>
<div v-if="chore.description" class="chore-description">{{ chore.description }}</div>
<span v-if="chore.subtext" class="item-time">{{ chore.subtext }}</span>
<div v-if="totalTime > 0" class="total-time">
Total Time: {{ formatDuration(totalTime) }}
</div>
</div>
</label>
<div class="neo-item-actions">
<button class="btn btn-sm btn-neutral" @click="toggleTimer" :disabled="chore.is_completed">
{{ isActiveTimer ? 'Stop' : 'Start' }}
</button>
<button class="btn btn-sm btn-neutral" @click="emit('open-details', chore)" title="View Details">
📋
</button>
<button class="btn btn-sm btn-neutral" @click="emit('open-history', chore)" title="View History">
📅
</button>
<button class="btn btn-sm btn-neutral" @click="emit('edit', chore)">
Edit
</button>
<button class="btn btn-sm btn-danger" @click="emit('delete', chore)">
Delete
</button>
</div>
</div>
<ul v-if="chore.child_chores && chore.child_chores.length" class="child-chore-list">
<ChoreItem v-for="child in chore.child_chores" :key="child.id" :chore="child" :time-entries="timeEntries"
:active-timer="activeTimer" @toggle-completion="emit('toggle-completion', $event)"
@edit="emit('edit', $event)" @delete="emit('delete', $event)"
@open-details="emit('open-details', $event)" @open-history="emit('open-history', $event)"
@start-timer="emit('start-timer', $event)"
@stop-timer="(chore, timeEntryId) => emit('stop-timer', chore, timeEntryId)" />
</ul>
</li>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, computed } from 'vue';
import type { ChoreWithCompletion } from '../types/chore';
import type { TimeEntry } from '../stores/timeEntryStore';
import { formatDuration } from '../utils/formatters';
const props = defineProps<{
chore: ChoreWithCompletion;
timeEntries: TimeEntry[];
activeTimer: TimeEntry | null;
}>();
const emit = defineEmits<{
(e: 'toggle-completion', chore: ChoreWithCompletion): void;
(e: 'edit', chore: ChoreWithCompletion): void;
(e: 'delete', chore: ChoreWithCompletion): void;
(e: 'open-details', chore: ChoreWithCompletion): void;
(e: 'open-history', chore: ChoreWithCompletion): void;
(e: 'start-timer', chore: ChoreWithCompletion): void;
(e: 'stop-timer', chore: ChoreWithCompletion, timeEntryId: number): void;
}>();
const isActiveTimer = computed(() => {
return props.activeTimer && props.activeTimer.chore_assignment_id === props.chore.current_assignment_id;
});
const totalTime = computed(() => {
return props.timeEntries.reduce((acc, entry) => acc + (entry.duration_seconds || 0), 0);
});
const toggleTimer = () => {
if (isActiveTimer.value) {
emit('stop-timer', props.chore, props.activeTimer!.id);
} else {
emit('start-timer', props.chore);
}
};
const getDueDateStatus = (chore: ChoreWithCompletion) => {
if (chore.is_completed) return 'completed';
const today = new Date();
today.setHours(0, 0, 0, 0);
const dueDate = new Date(chore.next_due_date);
dueDate.setHours(0, 0, 0, 0);
if (dueDate < today) return 'overdue';
if (dueDate.getTime() === today.getTime()) return 'due-today';
return 'upcoming';
};
</script>
<script lang="ts">
export default {
name: 'ChoreItem'
}
</script>
<style scoped lang="scss">
/* Neo-style list items */
.neo-list-item {
display: flex;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
position: relative;
transition: background-color 0.2s ease;
}
.neo-list-item:hover {
background-color: #f8f8f8;
}
.neo-list-item:last-child {
border-bottom: none;
}
.neo-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.neo-item-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: auto;
.btn {
margin-left: 0.25rem;
}
}
.neo-list-item:hover .neo-item-actions {
opacity: 1;
}
/* Custom Checkbox Styles */
.neo-checkbox-label {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.8em;
cursor: pointer;
position: relative;
width: 100%;
font-weight: 500;
color: #414856;
transition: color 0.3s ease;
margin-bottom: 0;
}
.neo-checkbox-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
position: relative;
height: 20px;
width: 20px;
outline: none;
border: 2px solid #b8c1d1;
margin: 0;
cursor: pointer;
background: transparent;
border-radius: 6px;
display: grid;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.neo-checkbox-label input[type="checkbox"]:hover {
border-color: var(--secondary);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]::before,
.neo-checkbox-label input[type="checkbox"]::after {
content: none;
}
.neo-checkbox-label input[type="checkbox"]::after {
content: "";
position: absolute;
opacity: 0;
left: 5px;
top: 1px;
width: 6px;
height: 12px;
border: solid var(--primary);
border-width: 0 3px 3px 0;
transform: rotate(45deg) scale(0);
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
transition-property: transform, opacity;
}
.neo-checkbox-label input[type="checkbox"]:checked {
border-color: var(--primary);
}
.neo-checkbox-label input[type="checkbox"]:checked::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
.checkbox-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
}
.checkbox-text-span {
position: relative;
transition: color 0.4s ease, opacity 0.4s ease;
width: fit-content;
font-weight: 500;
color: var(--dark);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Animated strikethrough line */
.checkbox-text-span::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(0);
transform-origin: right;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1);
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span {
color: var(--dark);
opacity: 0.6;
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::before {
transform: scaleX(1);
transform-origin: left;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1) 0.1s;
}
.neo-completed-static {
color: var(--dark);
opacity: 0.6;
position: relative;
}
.neo-completed-static::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(1);
transform-origin: left;
}
/* Status-based styling */
.status-completed {
opacity: 0.7;
}
/* Chore-specific styles */
.chore-main-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.chore-badges {
display: flex;
gap: 0.25rem;
}
.badge {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.badge-group {
background-color: #3b82f6;
color: white;
}
.badge-overdue {
background-color: #ef4444;
color: white;
}
.badge-due-today {
background-color: #f59e0b;
color: white;
}
.chore-description {
font-size: 0.875rem;
color: var(--dark);
opacity: 0.8;
margin-top: 0.25rem;
font-style: italic;
}
.item-time {
font-size: 0.9rem;
color: var(--dark);
opacity: 0.7;
}
.total-time {
font-size: 0.8rem;
color: #666;
margin-top: 0.25rem;
}
.child-chore-list {
list-style: none;
padding-left: 2rem;
margin-top: 0.5rem;
border-left: 2px solid #e5e7eb;
}
</style>

View File

@ -189,7 +189,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import { useNotificationStore } from '@/stores/notifications';
import { useAuthStore } from '@/stores/auth';
import type { ExpenseCreate, ExpenseSplitCreate } from '@/types/expense';

View File

@ -0,0 +1,102 @@
<template>
<VModal :model-value="isOpen" @update:model-value="closeModal" title="Create New Group">
<template #default>
<form @submit.prevent="onSubmit">
<VFormField label="Group Name" :error-message="formError ?? undefined">
<VInput type="text" v-model="groupName" required ref="groupNameInput" />
</VFormField>
</form>
</template>
<template #footer>
<VButton variant="neutral" @click="closeModal" type="button">Cancel</VButton>
<VButton type="submit" variant="primary" :disabled="loading" @click="onSubmit" class="ml-2">
<VSpinner v-if="loading" size="sm" />
Create
</VButton>
</template>
</VModal>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';
import { useVModel } from '@vueuse/core';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import { useNotificationStore } from '@/stores/notifications';
import VModal from '@/components/valerie/VModal.vue';
import VFormField from '@/components/valerie/VFormField.vue';
import VInput from '@/components/valerie/VInput.vue';
import VButton from '@/components/valerie/VButton.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
const props = defineProps<{
modelValue: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'created', newGroup: any): void;
}>();
const isOpen = useVModel(props, 'modelValue', emit);
const groupName = ref('');
const loading = ref(false);
const formError = ref<string | null>(null);
const notificationStore = useNotificationStore();
const groupNameInput = ref<InstanceType<typeof VInput> | null>(null);
watch(isOpen, (newVal) => {
if (newVal) {
groupName.value = '';
formError.value = null;
nextTick(() => {
// groupNameInput.value?.focus?.();
});
}
});
const closeModal = () => {
isOpen.value = false;
};
const validateForm = () => {
formError.value = null;
if (!groupName.value.trim()) {
formError.value = 'Name is required';
return false;
}
return true;
};
const onSubmit = async () => {
if (!validateForm()) {
return;
}
loading.value = true;
try {
const payload = { name: groupName.value };
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, payload);
notificationStore.addNotification({ message: 'Group created successfully', type: 'success' });
emit('created', response.data);
closeModal();
} catch (error: any) {
const message = error?.response?.data?.detail || (error instanceof Error ? error.message : 'Failed to create group');
formError.value = message;
notificationStore.addNotification({ message, type: 'error' });
console.error(message, error);
} finally {
loading.value = false;
}
};
</script>
<style>
.form-error-text {
color: var(--danger);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
</style>

View File

@ -29,7 +29,7 @@
<script setup lang="ts">
import { ref, watch, nextTick, computed } from 'vue';
import { useVModel } from '@vueuse/core'; // onClickOutside removed
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming this path is correct
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Assuming this path is correct
import { useNotificationStore } from '@/stores/notifications';
import VModal from '@/components/valerie/VModal.vue';
import VFormField from '@/components/valerie/VFormField.vue';
@ -38,6 +38,7 @@ import VTextarea from '@/components/valerie/VTextarea.vue';
import VSelect from '@/components/valerie/VSelect.vue';
import VButton from '@/components/valerie/VButton.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import type { Group } from '@/types/group';
const props = defineProps<{
modelValue: boolean;

View File

@ -0,0 +1,142 @@
<template>
<VModal :model-value="modelValue" :title="$t('listDetailPage.modals.costSummary.title')"
@update:modelValue="$emit('update:modelValue', false)" size="lg">
<template #default>
<div v-if="loading" class="text-center">
<VSpinner :label="$t('listDetailPage.loading.costSummary')" />
</div>
<VAlert v-else-if="error" type="error" :message="error" />
<div v-else-if="summary">
<div class="mb-3 cost-overview">
<p><strong>{{ $t('listDetailPage.modals.costSummary.totalCostLabel') }}</strong> {{
formatCurrency(summary.total_list_cost) }}</p>
<p><strong>{{ $t('listDetailPage.modals.costSummary.equalShareLabel') }}</strong> {{
formatCurrency(summary.equal_share_per_user) }}</p>
<p><strong>{{ $t('listDetailPage.modals.costSummary.participantsLabel') }}</strong> {{
summary.num_participating_users }}</p>
</div>
<h4>{{ $t('listDetailPage.modals.costSummary.userBalancesHeader') }}</h4>
<div class="table-container mt-2">
<table class="table">
<thead>
<tr>
<th>{{ $t('listDetailPage.modals.costSummary.tableHeaders.user') }}</th>
<th class="text-right">{{
$t('listDetailPage.modals.costSummary.tableHeaders.itemsAddedValue') }}</th>
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.amountDue')
}}</th>
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.balance')
}}</th>
</tr>
</thead>
<tbody>
<tr v-for="userShare in summary.user_balances" :key="userShare.user_id">
<td>{{ userShare.user_identifier }}</td>
<td class="text-right">{{ formatCurrency(userShare.items_added_value) }}</td>
<td class="text-right">{{ formatCurrency(userShare.amount_due) }}</td>
<td class="text-right">
<VBadge :text="formatCurrency(userShare.balance)"
:variant="parseFloat(String(userShare.balance)) >= 0 ? 'settled' : 'pending'" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p v-else>{{ $t('listDetailPage.modals.costSummary.emptyState') }}</p>
</template>
<template #footer>
<VButton variant="primary" @click="$emit('update:modelValue', false)">{{ $t('listDetailPage.buttons.close')
}}</VButton>
</template>
</VModal>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
import type { PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import VModal from '@/components/valerie/VModal.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import VAlert from '@/components/valerie/VAlert.vue';
import VBadge from '@/components/valerie/VBadge.vue';
import VButton from '@/components/valerie/VButton.vue';
interface UserCostShare {
user_id: number;
user_identifier: string;
items_added_value: string | number;
amount_due: string | number;
balance: string | number;
}
interface ListCostSummaryData {
list_id: number;
list_name: string;
total_list_cost: string | number;
num_participating_users: number;
equal_share_per_user: string | number;
user_balances: UserCostShare[];
}
defineProps({
modelValue: {
type: Boolean,
required: true,
},
summary: {
type: Object as PropType<ListCostSummaryData | null>,
default: null,
},
loading: {
type: Boolean,
default: false,
},
error: {
type: String as PropType<string | null>,
default: null,
},
});
defineEmits(['update:modelValue']);
const { t } = useI18n();
const formatCurrency = (value: string | number | undefined | null): string => {
if (value === undefined || value === null) return '$0.00';
if (typeof value === 'string' && !value.trim()) return '$0.00';
const numValue = typeof value === 'string' ? parseFloat(value) : value;
return isNaN(numValue) ? '$0.00' : `$${numValue.toFixed(2)}`;
};
</script>
<style scoped>
.cost-overview p {
margin-bottom: 0.5rem;
}
.table-container {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 0.75rem;
border-bottom: 1px solid #eee;
}
.table th {
text-align: left;
font-weight: 600;
background-color: #f8f9fa;
}
.text-right {
text-align: right;
}
</style>

View File

@ -0,0 +1,384 @@
<template>
<section class="neo-expenses-section">
<VCard v-if="isLoading && expenses.length === 0" class="py-10 text-center">
<VSpinner :label="$t('listDetailPage.expensesSection.loading')" size="lg" />
</VCard>
<VAlert v-else-if="error && expenses.length === 0" type="error" class="mt-4">
<p>{{ error }}</p>
<template #actions>
<VButton @click="$emit('retry-fetch')">
{{ $t('listDetailPage.expensesSection.retryButton') }}
</VButton>
</template>
</VAlert>
<VCard v-else-if="(!expenses || expenses.length === 0) && !isLoading" variant="empty-state" empty-icon="receipt"
:empty-title="$t('listDetailPage.expensesSection.emptyStateTitle')"
:empty-message="$t('listDetailPage.expensesSection.emptyStateMessage')" class="mt-4">
</VCard>
<div v-else class="neo-expense-list">
<div v-for="expense in expenses" :key="expense.id" class="neo-expense-item-wrapper">
<div class="neo-expense-item" @click="toggleExpense(expense.id)"
:class="{ 'is-expanded': isExpenseExpanded(expense.id) }">
<div class="expense-main-content">
<div class="expense-icon-container">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<line x1="12" x2="12" y1="2" y2="22"></line>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
</div>
<div class="expense-text-content">
<div class="neo-expense-header">
{{ expense.description }}
</div>
<div class="neo-expense-details">
{{ formatCurrency(expense.total_amount) }} &mdash;
{{ $t('listDetailPage.expensesSection.paidBy') }} <strong>{{ expense.paid_by_user?.name
||
expense.paid_by_user?.email }}</strong>
</div>
</div>
</div>
<div class="expense-side-content">
<span class="neo-expense-status" :class="getStatusClass(expense.overall_settlement_status)">
{{ getOverallExpenseStatusText(expense.overall_settlement_status) }}
</span>
<div class="expense-toggle-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="feather feather-chevron-down">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
</div>
</div>
<!-- Collapsible content -->
<div v-if="isExpenseExpanded(expense.id)" class="neo-splits-container">
<div class="neo-splits-list">
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
<div class="split-col split-user">
<strong>{{ split.user?.name || split.user?.email || `User ID: ${split.user_id}`
}}</strong>
</div>
<div class="split-col split-owes">
{{ $t('listDetailPage.expensesSection.owes') }} <strong>{{
formatCurrency(split.owed_amount) }}</strong>
</div>
<div class="split-col split-status">
<span class="neo-expense-status" :class="getStatusClass(split.status)">
{{ getSplitStatusText(split.status) }}
</span>
</div>
<div class="split-col split-paid-info">
<div v-if="split.paid_at" class="paid-details">
{{ $t('listDetailPage.expensesSection.paidAmount') }} {{
getPaidAmountForSplitDisplay(split)
}}
<span v-if="split.paid_at"> {{ $t('listDetailPage.expensesSection.onDate') }} {{ new
Date(split.paid_at).toLocaleDateString() }}</span>
</div>
</div>
<div class="split-col split-action">
<button
v-if="split.user_id === currentUserId && split.status !== ExpenseSplitStatusEnum.PAID"
class="btn btn-sm btn-primary" @click="$emit('settle-share', expense, split)"
:disabled="isSettlementLoading">
{{ $t('listDetailPage.expensesSection.settleShareButton') }}
</button>
</div>
<ul v-if="split.settlement_activities && split.settlement_activities.length > 0"
class="neo-settlement-activities">
<li v-for="activity in split.settlement_activities" :key="activity.id">
{{ $t('listDetailPage.expensesSection.activityLabel') }} {{
formatCurrency(activity.amount_paid) }}
{{
$t('listDetailPage.expensesSection.byUser') }} {{ activity.payer?.name || `User
${activity.paid_by_user_id}` }} {{ $t('listDetailPage.expensesSection.onDate') }} {{
new
Date(activity.paid_at).toLocaleDateString() }}
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits } from 'vue';
import type { PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Expense, ExpenseSplit } from '@/types/expense';
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense';
import { useListDetailStore } from '@/stores/listDetailStore';
import VCard from '@/components/valerie/VCard.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import VAlert from '@/components/valerie/VAlert.vue';
import VButton from '@/components/valerie/VButton.vue';
const props = defineProps({
expenses: {
type: Array as PropType<Expense[]>,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
error: {
type: String as PropType<string | null>,
default: null,
},
currentUserId: {
type: Number as PropType<number | null>,
required: true,
},
isSettlementLoading: {
type: Boolean,
default: false,
}
});
defineEmits(['retry-fetch', 'settle-share']);
const { t } = useI18n();
const listDetailStore = useListDetailStore();
const expandedExpenses = ref<Set<number>>(new Set());
const toggleExpense = (expenseId: number) => {
const newSet = new Set(expandedExpenses.value);
if (newSet.has(expenseId)) {
newSet.delete(expenseId);
} else {
newSet.add(expenseId);
}
expandedExpenses.value = newSet;
};
const isExpenseExpanded = (expenseId: number) => {
return expandedExpenses.value.has(expenseId);
};
const formatCurrency = (value: string | number | undefined | null): string => {
if (value === undefined || value === null) return '$0.00';
if (typeof value === 'string' && !value.trim()) return '$0.00';
const numValue = typeof value === 'string' ? parseFloat(value) : value;
return isNaN(numValue) ? '$0.00' : `$${numValue.toFixed(2)}`;
};
const getPaidAmountForSplitDisplay = (split: ExpenseSplit): string => {
const amount = listDetailStore.getPaidAmountForSplit(split.id);
return formatCurrency(amount);
};
const getSplitStatusText = (status: ExpenseSplitStatusEnum): string => {
switch (status) {
case ExpenseSplitStatusEnum.PAID: return t('listDetailPage.status.paid');
case ExpenseSplitStatusEnum.PARTIALLY_PAID: return t('listDetailPage.status.partiallyPaid');
case ExpenseSplitStatusEnum.UNPAID: return t('listDetailPage.status.unpaid');
default: return t('listDetailPage.status.unknown');
}
};
const getOverallExpenseStatusText = (status: ExpenseOverallStatusEnum): string => {
switch (status) {
case ExpenseOverallStatusEnum.PAID: return t('listDetailPage.status.settled');
case ExpenseOverallStatusEnum.PARTIALLY_PAID: return t('listDetailPage.status.partiallySettled');
case ExpenseOverallStatusEnum.UNPAID: return t('listDetailPage.status.unsettled');
default: return t('listDetailPage.status.unknown');
}
};
const getStatusClass = (status: ExpenseSplitStatusEnum | ExpenseOverallStatusEnum): string => {
if (status === ExpenseSplitStatusEnum.PAID || status === ExpenseOverallStatusEnum.PAID) return 'status-paid';
if (status === ExpenseSplitStatusEnum.PARTIALLY_PAID || status === ExpenseOverallStatusEnum.PARTIALLY_PAID) return 'status-partially_paid';
if (status === ExpenseSplitStatusEnum.UNPAID || status === ExpenseOverallStatusEnum.UNPAID) return 'status-unpaid';
return '';
};
</script>
<style scoped>
.neo-expenses-section {
padding: 0;
margin-top: 1.2rem;
}
.neo-expense-list {
background-color: rgb(255, 248, 240);
border-radius: 12px;
overflow: hidden;
border: 1px solid #f0e5d8;
}
.neo-expense-item-wrapper {
border-bottom: 1px solid #f0e5d8;
}
.neo-expense-item-wrapper:last-child {
border-bottom: none;
}
.neo-expense-item {
padding: 1rem 1.2rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background-color 0.2s ease;
}
.neo-expense-item:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.neo-expense-item.is-expanded .expense-toggle-icon {
transform: rotate(180deg);
}
.expense-main-content {
display: flex;
align-items: center;
gap: 1rem;
}
.expense-icon-container {
color: #d99a53;
}
.expense-text-content {
display: flex;
flex-direction: column;
}
.expense-side-content {
display: flex;
align-items: center;
gap: 1rem;
}
.expense-toggle-icon {
color: #888;
transition: transform 0.3s ease;
}
.neo-expense-header {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.1rem;
}
.neo-expense-details,
.neo-split-details {
font-size: 0.9rem;
color: #555;
margin-bottom: 0.3rem;
}
.neo-expense-details strong,
.neo-split-details strong {
color: #111;
}
.neo-expense-status {
display: inline-block;
padding: 0.25em 0.6em;
font-size: 0.85em;
font-weight: 700;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: 0.375rem;
margin-left: 0.5rem;
color: #22c55e;
}
.status-unpaid {
background-color: #fee2e2;
color: #dc2626;
}
.status-partially_paid {
background-color: #ffedd5;
color: #f97316;
}
.status-paid {
background-color: #dcfce7;
color: #22c55e;
}
.neo-splits-container {
padding: 0.5rem 1.2rem 1.2rem;
background-color: rgba(255, 255, 255, 0.5);
}
.neo-splits-list {
margin-top: 0rem;
padding-left: 0;
border-left: none;
}
.neo-split-item {
padding: 0.75rem 0;
border-bottom: 1px dashed #f0e5d8;
display: grid;
grid-template-areas:
"user owes status paid action"
"activities activities activities activities activities";
grid-template-columns: 1.5fr 1fr 1fr 1.5fr auto;
gap: 0.5rem 1rem;
align-items: center;
}
.neo-split-item:last-child {
border-bottom: none;
}
.split-col.split-user {
grid-area: user;
}
.split-col.split-owes {
grid-area: owes;
}
.split-col.split-status {
grid-area: status;
}
.split-col.split-paid-info {
grid-area: paid;
}
.split-col.split-action {
grid-area: action;
justify-self: end;
}
.split-col.neo-settlement-activities {
grid-area: activities;
font-size: 0.8em;
color: #555;
padding-left: 1em;
list-style-type: disc;
margin-top: 0.5em;
}
.neo-settlement-activities {
font-size: 0.8em;
color: #555;
padding-left: 1em;
list-style-type: disc;
margin-top: 0.5em;
}
.neo-settlement-activities li {
margin-top: 0.2em;
}
</style>

View File

@ -0,0 +1,255 @@
<template>
<div class="neo-item-list-cotainer">
<div v-for="group in groupedItems" :key="group.categoryName" class="category-group"
:class="{ 'highlight': supermarktMode && group.items.some(i => i.is_complete) }">
<h3 v-if="group.items.length" class="category-header">{{ group.categoryName }}</h3>
<draggable :list="group.items" item-key="id" handle=".drag-handle" @end="handleDragEnd"
:disabled="!isOnline || supermarktMode" class="neo-item-list" ghost-class="sortable-ghost"
drag-class="sortable-drag">
<template #item="{ element: item }">
<ListItem :item="item" :is-online="isOnline" :category-options="categoryOptions"
:supermarkt-mode="supermarktMode" @delete-item="$emit('delete-item', item)"
@checkbox-change="(item, checked) => $emit('checkbox-change', item, checked)"
@update-price="$emit('update-price', item)" @start-edit="$emit('start-edit', item)"
@save-edit="$emit('save-edit', item)" @cancel-edit="$emit('cancel-edit', item)"
@update:editName="item.editName = $event" @update:editQuantity="item.editQuantity = $event"
@update:editCategoryId="item.editCategoryId = $event"
@update:priceInput="item.priceInput = $event" />
</template>
</draggable>
</div>
<!-- New Add Item LI, integrated into the list -->
<li class="neo-list-item new-item-input-container" v-show="!supermarktMode">
<label class="neo-checkbox-label">
<input type="checkbox" disabled />
<input type="text" class="neo-new-item-input"
:placeholder="$t('listDetailPage.items.addItemForm.placeholder')" ref="itemNameInputRef"
:value="newItem.name"
@input="$emit('update:newItemName', ($event.target as HTMLInputElement).value)"
@keyup.enter="$emit('add-item')" @blur="handleNewItemBlur" @click.stop />
<VSelect
:model-value="newItem.category_id === null || newItem.category_id === undefined ? '' : newItem.category_id"
@update:modelValue="$emit('update:newItemCategoryId', $event === '' ? null : $event)"
:options="safeCategoryOptions" placeholder="Category" class="w-40" size="sm" />
</label>
</li>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineProps, defineEmits } from 'vue';
import type { PropType } from 'vue';
import draggable from 'vuedraggable';
import { useI18n } from 'vue-i18n';
import ListItem from './ListItem.vue';
import VSelect from '@/components/valerie/VSelect.vue';
import type { Item } from '@/types/item';
interface ItemWithUI extends Item {
updating: boolean;
deleting: boolean;
priceInput: string | number | null;
swiped: boolean;
isEditing?: boolean;
editName?: string;
editQuantity?: number | string | null;
editCategoryId?: number | null;
showFirework?: boolean;
group_id?: number;
}
const props = defineProps({
items: {
type: Array as PropType<ItemWithUI[]>,
required: true,
},
isOnline: {
type: Boolean,
required: true,
},
supermarktMode: {
type: Boolean,
default: false,
},
categoryOptions: {
type: Array as PropType<{ label: string; value: number | null }[]>,
required: true,
},
newItem: {
type: Object as PropType<{ name: string; category_id?: number | null }>,
required: true,
},
categories: {
type: Array as PropType<{ id: number; name: string }[]>,
required: true,
}
});
const emit = defineEmits([
'delete-item',
'checkbox-change',
'update-price',
'start-edit',
'save-edit',
'cancel-edit',
'add-item',
'handle-drag-end',
'update:newItemName',
'update:newItemCategoryId',
]);
const { t } = useI18n();
const itemNameInputRef = ref<HTMLInputElement | null>(null);
const safeCategoryOptions = computed(() => props.categoryOptions.map(opt => ({
...opt,
value: opt.value === null ? '' : opt.value
})));
const groupedItems = computed(() => {
const groups: Record<string, { categoryName: string; items: ItemWithUI[] }> = {};
props.items.forEach(item => {
const categoryId = item.category_id;
const category = props.categories.find(c => c.id === categoryId);
const categoryName = category ? category.name : t('listDetailPage.items.noCategory');
if (!groups[categoryName]) {
groups[categoryName] = { categoryName, items: [] };
}
groups[categoryName].items.push(item);
});
return Object.values(groups);
});
const handleDragEnd = (evt: any) => {
// We need to find the original item and its new global index
const item = evt.item.__vue__.$props.item;
let newIndex = 0;
let found = false;
for (const group of groupedItems.value) {
if (found) break;
for (const i of group.items) {
if (i.id === item.id) {
found = true;
break;
}
newIndex++;
}
}
// Create a new event object with the necessary info
const newEvt = {
item,
newIndex: newIndex,
oldIndex: evt.oldIndex, // This oldIndex is relative to the group
};
emit('handle-drag-end', newEvt);
};
const handleNewItemBlur = (event: FocusEvent) => {
const inputElement = event.target as HTMLInputElement;
if (inputElement.value.trim()) {
emit('add-item');
}
};
const focusNewItemInput = () => {
itemNameInputRef.value?.focus();
}
defineExpose({
focusNewItemInput
});
</script>
<style scoped>
.neo-checkbox-label {
display: flex;
align-items: center;
gap: 1rem;
}
.neo-item-list-container {
border: 3px solid #111;
border-radius: 18px;
background: var(--light);
box-shadow: 6px 6px 0 #111;
overflow: hidden;
}
.neo-item-list {
list-style: none;
padding: 1.2rem;
padding-inline: 0;
margin-bottom: 0;
border-bottom: 1px solid #eee;
background: var(--light);
}
.new-item-input-container {
list-style: none !important;
padding-inline: 3rem;
padding-bottom: 1.2rem;
}
.new-item-input-container .neo-checkbox-label {
width: 100%;
}
.neo-new-item-input {
all: unset;
height: 100%;
width: 100%;
font-size: 1.05rem;
font-weight: 500;
color: #444;
padding: 0.2rem 0;
border-bottom: 1px dashed #ccc;
transition: border-color 0.2s ease;
}
.neo-new-item-input:focus {
border-bottom-color: var(--secondary);
}
.neo-new-item-input::placeholder {
color: #999;
font-weight: 400;
}
.sortable-ghost {
opacity: 0.5;
background: #f0f0f0;
}
.sortable-drag {
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.category-group {
margin-bottom: 1.5rem;
}
.category-header {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.75rem;
padding: 0 1.2rem;
}
.category-group.highlight .neo-list-item:not(.is-complete) {
background-color: #e6f7ff;
}
.w-40 {
width: 20%;
}
</style>

View File

@ -0,0 +1,497 @@
<template>
<li class="neo-list-item"
:class="{ 'bg-gray-100 opacity-70': item.is_complete, 'item-pending-sync': isItemPendingSync }">
<div class="neo-item-content">
<!-- Drag Handle -->
<div class="drag-handle" v-if="isOnline && !supermarktMode">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="12" r="1"></circle>
<circle cx="9" cy="5" r="1"></circle>
<circle cx="9" cy="19" r="1"></circle>
<circle cx="15" cy="12" r="1"></circle>
<circle cx="15" cy="5" r="1"></circle>
<circle cx="15" cy="19" r="1"></circle>
</svg>
</div>
<!-- Content when NOT editing -->
<template v-if="!item.isEditing">
<label class="neo-checkbox-label" @click.stop>
<input type="checkbox" :checked="item.is_complete"
@change="$emit('checkbox-change', item, ($event.target as HTMLInputElement).checked)" />
<div class="checkbox-content">
<div class="item-text-container">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">
{{ item.name }}
</span>
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
<!-- User Information -->
<div class="item-user-info" v-if="item.added_by_user || item.completed_by_user">
<span v-if="item.added_by_user" class="user-badge added-by"
:title="$t('listDetailPage.items.addedByTooltip', { name: item.added_by_user.name })">
{{ $t('listDetailPage.items.addedBy') }} {{ item.added_by_user.name }}
</span>
<span v-if="item.is_complete && item.completed_by_user" class="user-badge completed-by"
:title="$t('listDetailPage.items.completedByTooltip', { name: item.completed_by_user.name })">
{{ $t('listDetailPage.items.completedBy') }} {{ item.completed_by_user.name }}
</span>
</div>
</div>
<div v-if="item.is_complete" class="neo-price-input">
<VInput type="number" :model-value="item.priceInput || ''" @update:modelValue="onPriceInput"
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24"
step="0.01" @blur="$emit('update-price', item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</div>
</label>
<div class="neo-item-actions" v-if="!supermarktMode">
<button class="neo-icon-button neo-edit-button" @click.stop="$emit('start-edit', item)"
:aria-label="$t('listDetailPage.items.editItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="$emit('delete-item', item)"
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</template>
<!-- Content WHEN editing -->
<template v-else>
<div class="inline-edit-form flex-grow flex items-center gap-2">
<VInput type="text" :model-value="item.editName ?? ''"
@update:modelValue="$emit('update:editName', $event)" required class="flex-grow" size="sm"
@keydown.enter.prevent="$emit('save-edit', item)"
@keydown.esc.prevent="$emit('cancel-edit', item)" />
<VInput type="number" :model-value="item.editQuantity || ''"
@update:modelValue="$emit('update:editQuantity', $event)" min="1" class="w-20" size="sm"
@keydown.enter.prevent="$emit('save-edit', item)"
@keydown.esc.prevent="$emit('cancel-edit', item)" />
<VSelect :model-value="categoryModel" @update:modelValue="categoryModel = $event"
:options="safeCategoryOptions" placeholder="Category" class="w-40" size="sm" />
</div>
<div class="neo-item-actions">
<button class="neo-icon-button neo-save-button" @click.stop="$emit('save-edit', item)"
:aria-label="$t('listDetailPage.buttons.saveChanges')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
</button>
<button class="neo-icon-button neo-cancel-button" @click.stop="$emit('cancel-edit', item)"
:aria-label="$t('listDetailPage.buttons.cancel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="$emit('delete-item', item)"
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</template>
</div>
</li>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, computed } from 'vue';
import type { PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Item } from '@/types/item';
import VInput from '@/components/valerie/VInput.vue';
import VSelect from '@/components/valerie/VSelect.vue';
import { useOfflineStore } from '@/stores/offline';
interface ItemWithUI extends Item {
updating: boolean;
deleting: boolean;
priceInput: string | number | null;
swiped: boolean;
isEditing?: boolean;
editName?: string;
editQuantity?: number | string | null;
editCategoryId?: number | null;
showFirework?: boolean;
}
const props = defineProps({
item: {
type: Object as PropType<ItemWithUI>,
required: true,
},
isOnline: {
type: Boolean,
required: true,
},
categoryOptions: {
type: Array as PropType<{ label: string; value: number | null }[]>,
required: true,
},
supermarktMode: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'delete-item',
'checkbox-change',
'update-price',
'start-edit',
'save-edit',
'cancel-edit',
'update:editName',
'update:editQuantity',
'update:editCategoryId',
'update:priceInput'
]);
const { t } = useI18n();
const offlineStore = useOfflineStore();
const safeCategoryOptions = computed(() => props.categoryOptions.map(opt => ({
...opt,
value: opt.value === null ? '' : opt.value
})));
const categoryModel = computed({
get: () => props.item.editCategoryId === null || props.item.editCategoryId === undefined ? '' : props.item.editCategoryId,
set: (value) => {
emit('update:editCategoryId', value === '' ? null : value);
}
});
const isItemPendingSync = computed(() => {
return offlineStore.pendingActions.some(action => {
if (action.type === 'update_list_item' || action.type === 'delete_list_item') {
const payload = action.payload as { listId: string; itemId: string };
return payload.itemId === String(props.item.id);
}
return false;
});
});
const onPriceInput = (value: string | number) => {
emit('update:priceInput', value);
}
</script>
<style scoped>
.neo-list-item {
padding: 1rem 0;
border-bottom: 1px solid #eee;
transition: background-color 0.2s ease;
}
.neo-list-item:last-child {
border-bottom: none;
}
.neo-list-item:hover {
background-color: #f8f8f8;
}
@media (max-width: 600px) {
.neo-list-item {
padding: 0.75rem 1rem;
}
}
.item-pending-sync {
/* You can add specific styling for pending items, e.g., a subtle glow or background */
}
.neo-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.neo-item-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: auto;
}
.neo-list-item:hover .neo-item-actions {
opacity: 1;
}
.inline-edit-form {
display: flex;
gap: 0.5rem;
align-items: center;
flex-grow: 1;
}
.neo-icon-button {
padding: 0.5rem;
border-radius: 4px;
color: #666;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
}
.neo-icon-button:hover {
background: #f0f0f0;
color: #333;
}
.neo-edit-button {
color: #3b82f6;
}
.neo-edit-button:hover {
background: #eef7fd;
color: #2563eb;
}
.neo-delete-button {
color: #ef4444;
}
.neo-delete-button:hover {
background: #fee2e2;
color: #dc2626;
}
.neo-save-button {
color: #22c55e;
}
.neo-save-button:hover {
background: #dcfce7;
color: #16a34a;
}
.neo-cancel-button {
color: #ef4444;
}
.neo-cancel-button:hover {
background: #fee2e2;
color: #dc2626;
}
/* Custom Checkbox Styles */
.neo-checkbox-label {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.8em;
cursor: pointer;
position: relative;
width: 100%;
font-weight: 500;
color: #414856;
transition: color 0.3s ease;
}
.neo-checkbox-label input[type="checkbox"] {
appearance: none;
position: relative;
height: 20px;
width: 20px;
outline: none;
border: 2px solid #b8c1d1;
margin: 0;
cursor: pointer;
background: transparent;
border-radius: 6px;
display: grid;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.neo-checkbox-label input[type="checkbox"]:hover {
border-color: var(--secondary);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]::after {
content: "";
position: absolute;
opacity: 0;
left: 5px;
top: 1px;
width: 6px;
height: 12px;
border: solid var(--primary);
border-width: 0 3px 3px 0;
transform: rotate(45deg) scale(0);
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
transition-property: transform, opacity;
}
.neo-checkbox-label input[type="checkbox"]:checked {
border-color: var(--primary);
}
.neo-checkbox-label input[type="checkbox"]:checked::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
.checkbox-content {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.checkbox-text-span {
position: relative;
transition: color 0.4s ease, opacity 0.4s ease;
width: fit-content;
}
.checkbox-text-span::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(0);
transform-origin: right;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1);
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span {
color: var(--dark);
opacity: 0.6;
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::before {
transform: scaleX(1);
transform-origin: left;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1) 0.1s;
}
.neo-completed-static {
color: var(--dark);
opacity: 0.6;
position: relative;
}
.neo-completed-static::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(1);
transform-origin: left;
}
.neo-price-input {
display: inline-flex;
align-items: center;
margin-left: 0.5rem;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.neo-list-item:hover .neo-price-input {
opacity: 1;
}
.drag-handle {
cursor: grab;
padding: 0.5rem;
color: #666;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.neo-list-item:hover .drag-handle {
opacity: 0.5;
}
.drag-handle:hover {
opacity: 1 !important;
color: #333;
}
.drag-handle:active {
cursor: grabbing;
}
/* User Information Styles */
.item-text-container {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.item-user-info {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.user-badge {
font-size: 0.75rem;
color: #6b7280;
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid #e5e7eb;
white-space: nowrap;
}
.user-badge.added-by {
color: #059669;
background: #ecfdf5;
border-color: #a7f3d0;
}
.user-badge.completed-by {
color: #7c3aed;
background: #f3e8ff;
border-color: #c4b5fd;
}
</style>

View File

@ -0,0 +1,114 @@
<template>
<VModal :model-value="modelValue" :title="$t('listDetailPage.modals.ocr.title')"
@update:modelValue="$emit('update:modelValue', $event)">
<template #default>
<div v-if="ocrLoading" class="text-center">
<VSpinner :label="$t('listDetailPage.loading.ocrProcessing')" />
</div>
<VList v-else-if="ocrItems.length > 0">
<VListItem v-for="(ocrItem, index) in ocrItems" :key="index">
<div class="flex items-center gap-2">
<VInput type="text" v-model="ocrItem.name" class="flex-grow" required />
<VButton variant="danger" size="sm" :icon-only="true" iconLeft="trash"
@click="ocrItems.splice(index, 1)" />
</div>
</VListItem>
</VList>
<VFormField v-else :label="$t('listDetailPage.modals.ocr.uploadLabel')"
:error-message="ocrError || undefined">
<VInput type="file" id="ocrFile" accept="image/*" @change="handleOcrFileUpload" ref="ocrFileInputRef"
:model-value="''" />
</VFormField>
</template>
<template #footer>
<VButton variant="neutral" @click="$emit('update:modelValue', false)">{{ $t('listDetailPage.buttons.cancel')
}}</VButton>
<VButton v-if="ocrItems.length > 0" type="button" variant="primary" @click="confirmAddItems"
:disabled="isAdding">
<VSpinner v-if="isAdding" size="sm" />
{{ $t('listDetailPage.buttons.addItems') }}
</VButton>
</template>
</VModal>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits } from 'vue';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import { getApiErrorMessage } from '@/utils/errors';
import VModal from '@/components/valerie/VModal.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import VList from '@/components/valerie/VList.vue';
import VListItem from '@/components/valerie/VListItem.vue';
import VInput from '@/components/valerie/VInput.vue';
import VButton from '@/components/valerie/VButton.vue';
import VFormField from '@/components/valerie/VFormField.vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
isAdding: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'add-items']);
const { t } = useI18n();
const ocrLoading = ref(false);
const ocrItems = ref<{ name: string }[]>([]);
const ocrError = ref<string | null>(null);
const ocrFileInputRef = ref<InstanceType<typeof VInput> | null>(null);
const handleOcrFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
handleOcrUpload(target.files[0]);
}
};
const handleOcrUpload = async (file: File) => {
if (!file) return;
ocrLoading.value = true;
ocrError.value = null;
ocrItems.value = [];
try {
const formData = new FormData();
formData.append('image_file', file);
const response = await apiClient.post(API_ENDPOINTS.OCR.PROCESS, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
ocrItems.value = response.data.extracted_items
.map((nameStr: string) => ({ name: nameStr.trim() }))
.filter((item: { name: string }) => item.name);
if (ocrItems.value.length === 0) {
ocrError.value = t('listDetailPage.errors.ocrNoItems');
}
} catch (err) {
ocrError.value = getApiErrorMessage(err, 'listDetailPage.errors.ocrFailed', t);
} finally {
ocrLoading.value = false;
// Reset file input
if (ocrFileInputRef.value?.$el) {
const input = ocrFileInputRef.value.$el.querySelector ? ocrFileInputRef.value.$el.querySelector('input') : ocrFileInputRef.value.$el;
if (input) input.value = '';
}
}
};
const confirmAddItems = () => {
emit('add-items', ocrItems.value);
};
watch(() => props.modelValue, (newVal) => {
if (newVal) {
ocrItems.value = [];
ocrError.value = null;
}
});
</script>

View File

@ -0,0 +1,73 @@
<template>
<VModal :model-value="modelValue" :title="$t('listDetailPage.modals.settleShare.title')"
@update:modelValue="$emit('update:modelValue', false)" size="md">
<template #default>
<div v-if="isLoading" class="text-center">
<VSpinner :label="$t('listDetailPage.loading.settlement')" />
</div>
<div v-else>
<p>
{{ $t('listDetailPage.modals.settleShare.settleAmountFor', { userName: userName }) }}
</p>
<VFormField :label="$t('listDetailPage.modals.settleShare.amountLabel')"
:error-message="error || undefined">
<VInput type="number" :model-value="amount" @update:modelValue="$emit('update:amount', $event)"
required />
</VFormField>
</div>
</template>
<template #footer>
<VButton variant="neutral" @click="$emit('update:modelValue', false)">
{{ $t('listDetailPage.modals.settleShare.cancelButton') }}
</VButton>
<VButton variant="primary" @click="$emit('confirm')" :disabled="isLoading">
{{ $t('listDetailPage.modals.settleShare.confirmButton') }}
</VButton>
</template>
</VModal>
</template>
<script setup lang="ts">
import { computed, defineProps, defineEmits } from 'vue';
import type { PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ExpenseSplit } from '@/types/expense';
import VModal from '@/components/valerie/VModal.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import VFormField from '@/components/valerie/VFormField.vue';
import VInput from '@/components/valerie/VInput.vue';
import VButton from '@/components/valerie/VButton.vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
split: {
type: Object as PropType<ExpenseSplit | null>,
required: true,
},
amount: {
type: String,
required: true,
},
error: {
type: String as PropType<string | null>,
default: null,
},
isLoading: {
type: Boolean,
default: false,
}
});
defineEmits(['update:modelValue', 'update:amount', 'confirm']);
const { t } = useI18n();
const userName = computed(() => {
if (!props.split) return '';
return props.split.user?.name || props.split.user?.email || `User ID: ${props.split.user_id}`;
});
</script>

View File

@ -1,10 +1,5 @@
<template>
<button
:type="type"
:class="buttonClasses"
:disabled="disabled"
@click="handleClick"
>
<button :type="type" :class="buttonClasses" :disabled="disabled" @click="handleClick">
<VIcon v-if="iconLeft && !iconOnly" :name="iconLeft" :size="iconSize" class="mr-1" />
<VIcon v-if="iconOnly && (iconLeft || iconRight)" :name="iconNameForIconOnly" :size="iconSize" />
<span v-if="!iconOnly" :class="{ 'sr-only': iconOnly && (iconLeft || iconRight) }">
@ -15,10 +10,10 @@
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
import { defineComponent, computed, type PropType } from 'vue';
import VIcon from './VIcon.vue'; // Assuming VIcon.vue is in the same directory
type ButtonVariant = 'primary' | 'secondary' | 'neutral' | 'danger';
type ButtonVariant = 'primary' | 'secondary' | 'neutral' | 'danger' | 'success';
type ButtonSize = 'sm' | 'md' | 'lg'; // Added 'lg' for consistency, though not in SCSS class list initially
type ButtonType = 'button' | 'submit' | 'reset';
@ -35,7 +30,7 @@ export default defineComponent({
variant: {
type: String as PropType<ButtonVariant>,
default: 'primary',
validator: (value: string) => ['primary', 'secondary', 'neutral', 'danger'].includes(value),
validator: (value: string) => ['primary', 'secondary', 'neutral', 'danger', 'success'].includes(value),
},
size: {
type: String as PropType<ButtonSize>,
@ -162,6 +157,12 @@ export default defineComponent({
border-color: #dc3545;
}
.btn-success {
background-color: #28a745; // Example success color
color: white;
border-color: #28a745;
}
// Sizes
.btn-sm {
padding: 0.25em 0.5em;
@ -180,9 +181,18 @@ export default defineComponent({
// Icon only
.btn-icon-only {
padding: 0.5em; // Adjust padding for icon-only buttons
// Ensure VIcon fills the space or adjust VIcon size if needed
& .mr-1 { margin-right: 0 !important; } // Remove margin if accidentally applied
& .ml-1 { margin-left: 0 !important; } // Remove margin if accidentally applied
& .mr-1 {
margin-right: 0 !important;
}
// Remove margin if accidentally applied
& .ml-1 {
margin-left: 0 !important;
}
// Remove margin if accidentally applied
}
.sr-only {
@ -201,6 +211,7 @@ export default defineComponent({
.mr-1 {
margin-right: 0.25em;
}
.ml-1 {
margin-left: 0.25em;
}

View File

@ -9,6 +9,7 @@ export const API_ENDPOINTS = {
// Auth
AUTH: {
LOGIN: '/auth/jwt/login',
GUEST: '/auth/guest',
SIGNUP: '/auth/register',
LOGOUT: '/auth/jwt/logout',
REFRESH: '/auth/jwt/refresh',
@ -21,11 +22,11 @@ export const API_ENDPOINTS = {
USERS: {
PROFILE: '/users/me',
UPDATE_PROFILE: '/users/me',
PASSWORD: '/api/v1/users/password',
AVATAR: '/api/v1/users/avatar',
SETTINGS: '/api/v1/users/settings',
NOTIFICATIONS: '/api/v1/users/notifications',
PREFERENCES: '/api/v1/users/preferences',
PASSWORD: '/users/password',
AVATAR: '/users/avatar',
SETTINGS: '/users/settings',
NOTIFICATIONS: '/users/notifications',
PREFERENCES: '/users/preferences',
},
// Lists
@ -42,10 +43,16 @@ export const API_ENDPOINTS = {
COMPLETE: (listId: string) => `/lists/${listId}/complete`,
REOPEN: (listId: string) => `/lists/${listId}/reopen`,
ARCHIVE: (listId: string) => `/lists/${listId}/archive`,
RESTORE: (listId: string) => `/lists/${listId}/restore`,
UNARCHIVE: (listId: string) => `/lists/${listId}/unarchive`,
DUPLICATE: (listId: string) => `/lists/${listId}/duplicate`,
EXPORT: (listId: string) => `/lists/${listId}/export`,
IMPORT: '/lists/import',
ARCHIVED: '/lists/archived',
},
CATEGORIES: {
BASE: '/categories',
BY_ID: (id: string) => `/categories/${id}`,
},
// Groups
@ -62,13 +69,15 @@ export const API_ENDPOINTS = {
SETTINGS: (groupId: string) => `/groups/${groupId}/settings`,
ROLES: (groupId: string) => `/groups/${groupId}/roles`,
ROLE: (groupId: string, roleId: string) => `/groups/${groupId}/roles/${roleId}`,
GENERATE_SCHEDULE: (groupId: string) => `/groups/${groupId}/chores/generate-schedule`,
CHORE_HISTORY: (groupId: string) => `/groups/${groupId}/chores/history`,
},
// Invites
INVITES: {
BASE: '/invites',
BY_ID: (id: string) => `/invites/${id}`,
ACCEPT: '/invites/accept',
ACCEPT: (id: string) => `/invites/accept/${id}`,
DECLINE: (id: string) => `/invites/decline/${id}`,
REVOKE: (id: string) => `/invites/revoke/${id}`,
LIST: '/invites',
@ -120,4 +129,14 @@ export const API_ENDPOINTS = {
METRICS: '/health/metrics',
LOGS: '/health/logs',
},
CHORES: {
BASE: '/chores',
BY_ID: (id: number) => `/chores/${id}`,
HISTORY: (id: number) => `/chores/${id}/history`,
ASSIGNMENTS: (choreId: number) => `/chores/${choreId}/assignments`,
ASSIGNMENT_BY_ID: (id: number) => `/chores/assignments/${id}`,
TIME_ENTRIES: (assignmentId: number) => `/chores/assignments/${assignmentId}/time-entries`,
TIME_ENTRY: (id: number) => `/chores/time-entries/${id}`,
},
}

View File

@ -1,18 +0,0 @@
import { api } from '@/services/api';
import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from './api-config';
// Helper function to get full API URL
export const getApiUrl = (endpoint: string): string => {
return `${API_BASE_URL}/api/${API_VERSION}${endpoint}`;
};
// Helper function to make API calls
export const apiClient = {
get: (endpoint: string, config = {}) => api.get(getApiUrl(endpoint), config),
post: (endpoint: string, data = {}, config = {}) => api.post(getApiUrl(endpoint), data, config),
put: (endpoint: string, data = {}, config = {}) => api.put(getApiUrl(endpoint), data, config),
patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
};
export { API_ENDPOINTS };

View File

@ -627,5 +627,15 @@
"sampleTodosHeader": "Beispiel-Todos (aus IndexPage-Daten)",
"totalCountLabel": "Gesamtzahl aus Meta:",
"noTodos": "Keine Todos zum Anzeigen."
},
"languageSelector": {
"title": "Sprache",
"languages": {
"en": "English",
"de": "Deutsch",
"nl": "Nederlands",
"fr": "Français",
"es": "Español"
}
}
}

View File

@ -97,6 +97,8 @@
"addChore": "+",
"edit": "Edit",
"delete": "Delete",
"editChore": "Edit Chore",
"createChore": "Create Chore",
"empty": {
"title": "No Chores Yet",
"message": "Get started by adding your first chore!",
@ -170,6 +172,23 @@
"loadingLabel": "Loading group details...",
"retryButton": "Retry",
"groupNotFound": "Group not found or an error occurred.",
"lists": {
"title": "Group Lists"
},
"generateScheduleModal": {
"title": "Generate Schedule"
},
"activityLog": {
"title": "Activity Log",
"emptyState": "No activity to show yet."
},
"chores": {
"title": "Group Chores",
"manageButton": "Manage Chores",
"duePrefix": "Due:",
"emptyState": "No chores scheduled. Click \"Manage Chores\" to create some!",
"generateScheduleButton": "Generate Schedule"
},
"members": {
"title": "Group Members",
"defaultRole": "Member",
@ -201,12 +220,6 @@
"console": {
"noActiveInvite": "No active invite code found for this group."
},
"chores": {
"title": "Group Chores",
"manageButton": "Manage Chores",
"duePrefix": "Due:",
"emptyState": "No chores scheduled. Click \"Manage Chores\" to create some!"
},
"expenses": {
"title": "Group Expenses",
"manageButton": "Manage Expenses",
@ -362,7 +375,8 @@
"confirm": "Confirm",
"saveChanges": "Save Changes",
"close": "Close",
"costSummary": "Cost Summary"
"costSummary": "Cost Summary",
"exitSupermarketMode": "Exit Supermarket Mode"
},
"badges": {
"groupList": "Group List",
@ -381,7 +395,12 @@
},
"pricePlaceholder": "Price",
"editItemAriaLabel": "Edit item",
"deleteItemAriaLabel": "Delete item"
"deleteItemAriaLabel": "Delete item",
"addedBy": "Added by",
"completedBy": "Completed by",
"addedByTooltip": "This item was added by {name}",
"completedByTooltip": "This item was completed by {name}",
"noCategory": "No Category"
},
"modals": {
"ocr": {
@ -445,6 +464,8 @@
"addExpenseButton": "Add Expense",
"loading": "Loading expenses...",
"emptyState": "No expenses recorded for this list yet.",
"emptyStateTitle": "No Expenses",
"emptyStateMessage": "Add your first expense to get started.",
"paidBy": "Paid by:",
"onDate": "on",
"owes": "owes",
@ -555,5 +576,15 @@
"sampleTodosHeader": "Sample Todos (from IndexPage data)",
"totalCountLabel": "Total count from meta:",
"noTodos": "No todos to display."
},
"languageSelector": {
"title": "Language",
"languages": {
"en": "English",
"de": "Deutsch",
"nl": "Nederlands",
"fr": "Français",
"es": "Español"
}
}
}

View File

@ -627,5 +627,15 @@
"sampleTodosHeader": "Tareas de ejemplo (de datos de IndexPage)",
"totalCountLabel": "Recuento total de meta:",
"noTodos": "No hay tareas para mostrar."
},
"languageSelector": {
"title": "Idioma",
"languages": {
"en": "English",
"de": "Deutsch",
"nl": "Nederlands",
"fr": "Français",
"es": "Español"
}
}
}

View File

@ -627,5 +627,15 @@
"sampleTodosHeader": "Exemples de tâches (depuis les données IndexPage)",
"totalCountLabel": "Nombre total depuis meta :",
"noTodos": "Aucune tâche à afficher."
},
"languageSelector": {
"title": "Langue",
"languages": {
"en": "English",
"de": "Deutsch",
"nl": "Nederlands",
"fr": "Français",
"es": "Español"
}
}
}

View File

@ -94,6 +94,11 @@
},
"choresPage": {
"title": "Taken",
"addChore": "+",
"edit": "Bewerken",
"delete": "Verwijderen",
"editChore": "Taak bewerken",
"createChore": "Nieuwe taak",
"tabs": {
"overdue": "Achterstallig",
"today": "Vandaag",
@ -339,6 +344,16 @@
"partiallyPaid": "Gedeeltelijk betaald",
"unpaid": "Onbetaald",
"unknown": "Onbekende status"
},
"lists": {
"title": "Groepslijsten"
},
"generateScheduleModal": {
"title": "Schema genereren"
},
"activityLog": {
"title": "Activiteitenlogboek",
"emptyState": "Nog geen activiteiten om weer te geven."
}
},
"accountPage": {
@ -627,5 +642,15 @@
"sampleTodosHeader": "Voorbeeldtaken (uit IndexPage-gegevens)",
"totalCountLabel": "Totaal aantal uit meta:",
"noTodos": "Geen taken om weer te geven."
},
"languageSelector": {
"title": "Taal",
"languages": {
"en": "English",
"de": "Deutsch",
"nl": "Nederlands",
"fr": "Français",
"es": "Español"
}
}
}

View File

@ -7,7 +7,6 @@
</template>
<script setup lang="ts">
// No specific logic for AuthLayout
</script>
<style lang="scss" scoped>
@ -15,13 +14,12 @@
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: var(--bg-color-page, #f0f2f5);
}
.auth-page-container {
width: 100%;
max-width: 450px; // Max width for login/signup forms
max-width: 450px;
padding: 2rem;
}
</style>

View File

@ -1,22 +1,68 @@
<template>
<div class="main-layout">
<header class="app-header">
<div class="toolbar-title">mitlist</div>
<div class="user-menu" v-if="authStore.isAuthenticated">
<button @click="toggleUserMenu" class="user-menu-button">
<!-- Placeholder for user icon -->
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ff7b54">
<path d="M0 0h24v24H0z" fill="none" />
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
<div v-if="authStore.isAuthenticated" class="header-controls">
<div class="control-item">
<button ref="addMenuTrigger" class="icon-button" :class="{ 'is-active': addMenuOpen }"
:aria-expanded="addMenuOpen" aria-controls="add-menu-dropdown" aria-label="Add new list or group"
@click="toggleAddMenu">
<span class="material-icons">add_circle_outline</span>
</button>
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
<a href="#" @click.prevent="handleLogout">Logout</a>
<Transition name="dropdown-fade">
<div v-if="addMenuOpen" id="add-menu-dropdown" ref="addMenuDropdown" class="dropdown-menu add-dropdown"
role="menu">
<div class="dropdown-header">{{ $t('addSelector.title') }}</div>
<a href="#" role="menuitem" @click.prevent="handleAddList">{{ $t('addSelector.addList') }}</a>
<a href="#" role="menuitem" @click.prevent="handleAddGroup">{{ $t('addSelector.addGroup') }}</a>
</div>
</Transition>
</div>
<div class="control-item">
<button ref="languageMenuTrigger" class="icon-button language-button"
:class="{ 'is-active': languageMenuOpen }" :aria-expanded="languageMenuOpen"
aria-controls="language-menu-dropdown"
:aria-label="`Change language, current: ${currentLanguageCode.toUpperCase()}`" @click="toggleLanguageMenu">
<span class="material-icons-outlined">translate</span>
<span class="current-language">{{ currentLanguageCode.toUpperCase() }}</span>
</button>
<Transition name="dropdown-fade">
<div v-if="languageMenuOpen" id="language-menu-dropdown" ref="languageMenuDropdown"
class="dropdown-menu language-dropdown" role="menu">
<div class="dropdown-header">{{ $t('languageSelector.title') }}</div>
<a v-for="(name, code) in availableLanguages" :key="code" href="#" role="menuitem" class="language-option"
:class="{ 'active': currentLanguageCode === code }" @click.prevent="changeLanguage(code)">
{{ name }}
</a>
</div>
</Transition>
</div>
<div class="control-item">
<button ref="userMenuTrigger" class="icon-button user-menu-button" :class="{ 'is-active': userMenuOpen }"
:aria-expanded="userMenuOpen" aria-controls="user-menu-dropdown" aria-label="User menu"
@click="toggleUserMenu">
<img v-if="authStore.user?.avatarUrl" :src="authStore.user.avatarUrl" alt="User Avatar"
class="user-avatar" />
<span v-else class="material-icons">account_circle</span>
</button>
<Transition name="dropdown-fade">
<div v-if="userMenuOpen" id="user-menu-dropdown" ref="userMenuDropdown" class="dropdown-menu" role="menu">
<div v-if="authStore.user" class="dropdown-user-info">
<strong>{{ authStore.user.name }}</strong>
<small>{{ authStore.user.email }}</small>
</div>
<a href="#" role="menuitem" @click.prevent="handleLogout">Logout</a>
</div>
</Transition>
</div>
</div>
</header>
<main class="page-container">
<router-view v-slot="{ Component, route }">
<keep-alive v-if="route.meta.keepAlive">
@ -34,113 +80,158 @@
<span class="material-icons">list</span>
<span class="tab-text">Lists</span>
</router-link>
<a @click.prevent="navigateToGroups" href="/groups" class="tab-item"
:class="{ 'active': $route.path.startsWith('/groups') }">
<router-link to="/groups" class="tab-item" :class="{ 'active': $route.path.startsWith('/groups') }"
@click.prevent="navigateToGroups">
<span class="material-icons">group</span>
<span class="tab-text">Groups</span>
</a>
</router-link>
<router-link to="/chores" class="tab-item" active-class="active">
<span class="material-icons">person_pin_circle</span>
<span class="material-icons">task_alt</span>
<span class="tab-text">Chores</span>
</router-link>
<!-- <router-link to="/account" class="tab-item" active-class="active">
<span class="material-icons">person</span>
<span class="tab-text">Account</span>
</router-link> -->
<router-link to="/expenses" class="tab-item" active-class="active">
<span class="material-icons">payments</span>
<span class="tab-text">Expenses</span>
</router-link>
</nav>
</footer>
<CreateListModal v-model="showCreateListModal" @created="handleListCreated" />
<CreateGroupModal v-model="showCreateGroupModal" @created="handleGroupCreated" />
</div>
</template>
<script setup lang="ts">
import { ref, defineComponent, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import OfflineIndicator from '@/components/OfflineIndicator.vue';
import { onClickOutside } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications';
import { useGroupStore } from '@/stores/groupStore';
defineComponent({
name: 'MainLayout'
});
import { useI18n } from 'vue-i18n';
import CreateListModal from '@/components/CreateListModal.vue';
import CreateGroupModal from '@/components/CreateGroupModal.vue';
import { onClickOutside } from '@vueuse/core';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
const groupStore = useGroupStore();
const { t, locale } = useI18n();
// Add initialization logic
const initializeApp = async () => {
if (authStore.isAuthenticated) {
try {
await authStore.fetchCurrentUser();
} catch (error) {
console.error('Failed to initialize app:', error);
// Don't automatically logout - let the API interceptor handle token refresh
// The response interceptor will handle 401s and refresh tokens automatically
}
}
};
const addMenuOpen = ref(false);
const addMenuDropdown = ref<HTMLElement | null>(null);
const addMenuTrigger = ref<HTMLElement | null>(null);
const toggleAddMenu = () => { addMenuOpen.value = !addMenuOpen.value; };
onClickOutside(addMenuDropdown, () => { addMenuOpen.value = false; }, { ignore: [addMenuTrigger] });
// Call initialization when component is mounted
onMounted(() => {
initializeApp();
if (authStore.isAuthenticated) {
groupStore.fetchGroups();
}
});
const languageMenuOpen = ref(false);
const languageMenuDropdown = ref<HTMLElement | null>(null);
const languageMenuTrigger = ref<HTMLElement | null>(null);
const toggleLanguageMenu = () => { languageMenuOpen.value = !languageMenuOpen.value; };
onClickOutside(languageMenuDropdown, () => { languageMenuOpen.value = false; }, { ignore: [languageMenuTrigger] });
const userMenuOpen = ref(false);
const userMenuDropdown = ref<HTMLElement | null>(null);
const userMenuTrigger = ref<HTMLElement | null>(null);
const toggleUserMenu = () => { userMenuOpen.value = !userMenuOpen.value; };
onClickOutside(userMenuDropdown, () => { userMenuOpen.value = false; }, { ignore: [userMenuTrigger] });
const toggleUserMenu = () => {
userMenuOpen.value = !userMenuOpen.value;
const availableLanguages = computed(() => ({
en: t('languageSelector.languages.en'),
de: t('languageSelector.languages.de'),
nl: t('languageSelector.languages.nl'),
fr: t('languageSelector.languages.fr'),
es: t('languageSelector.languages.es')
}));
const currentLanguageCode = computed(() => locale.value);
const changeLanguage = (languageCode: string) => {
locale.value = languageCode;
localStorage.setItem('language', languageCode);
languageMenuOpen.value = false;
notificationStore.addNotification({
type: 'success',
message: `Language changed to ${availableLanguages.value[languageCode as keyof typeof availableLanguages.value]}`,
});
};
onClickOutside(userMenuDropdown, () => {
userMenuOpen.value = false;
}, { ignore: ['.user-menu-button'] });
const showCreateListModal = ref(false);
const showCreateGroupModal = ref(false);
const handleAddList = () => {
addMenuOpen.value = false;
showCreateListModal.value = true;
};
const handleAddGroup = () => {
addMenuOpen.value = false;
showCreateGroupModal.value = true;
};
const handleListCreated = (newList: any) => {
notificationStore.addNotification({ message: `List '${newList.name}' created successfully`, type: 'success' });
showCreateListModal.value = false;
};
const handleGroupCreated = (newGroup: any) => {
notificationStore.addNotification({ message: `Group '${newGroup.name}' created successfully`, type: 'success' });
showCreateGroupModal.value = false;
groupStore.fetchGroups();
};
const handleLogout = async () => {
try {
authStore.logout(); // Pinia action
notificationStore.addNotification({
type: 'success',
message: 'Logged out successfully',
});
await router.push('/auth/login'); // Adjusted path
userMenuOpen.value = false;
authStore.logout();
notificationStore.addNotification({ type: 'success', message: 'Logged out successfully' });
await router.push('/auth/login');
} catch (error: unknown) {
notificationStore.addNotification({
type: 'error',
message: error instanceof Error ? error.message : 'Logout failed',
});
}
userMenuOpen.value = false;
};
const navigateToGroups = () => {
// The groups should have been fetched on mount, but we can check isLoading
if (groupStore.isLoading) {
// Maybe show a toast or do nothing
console.log('Groups are still loading...');
return;
}
if (groupStore.isLoading) return;
if (groupStore.groupCount === 1 && groupStore.firstGroupId) {
router.push(`/groups/${groupStore.firstGroupId}`);
} else {
router.push('/groups');
}
};
onMounted(async () => {
if (authStore.isAuthenticated) {
try {
// Only fetch user if we don't have user data yet
if (!authStore.user) {
await authStore.fetchCurrentUser();
}
await groupStore.fetchGroups();
} catch (error) {
console.error('Failed to initialize app data:', error);
}
}
const savedLanguage = localStorage.getItem('language');
if (savedLanguage && Object.keys(availableLanguages.value).includes(savedLanguage)) {
locale.value = savedLanguage;
}
});
</script>
<style lang="scss" scoped>
@import url('https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined');
.main-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #f9f9f9;
}
.app-header {
@ -150,7 +241,7 @@ const navigateToGroups = () => {
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
position: sticky;
top: 0;
z-index: 100;
@ -163,13 +254,19 @@ const navigateToGroups = () => {
color: var(--primary);
}
.user-menu {
.header-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-item {
position: relative;
}
.user-menu-button {
.icon-button {
background: none;
border: none;
border: 1px solid transparent;
color: var(--primary);
cursor: pointer;
padding: 0.5rem;
@ -177,29 +274,66 @@ const navigateToGroups = () => {
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
background-color: rgba(255, 123, 84, 0.1);
}
&.is-active {
background-color: rgba(255, 123, 84, 0.15);
box-shadow: 0 0 0 2px rgba(255, 123, 84, 0.3);
}
.material-icons,
.material-icons-outlined {
font-size: 26px;
}
}
.language-button {
border-radius: 20px;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
}
.current-language {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.5px;
}
.user-menu-button {
padding: 0;
width: 40px;
height: 40px;
overflow: hidden;
.user-avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.dropdown-menu {
position: absolute;
right: 0;
top: calc(100% + 5px);
color: var(--primary);
background-color: #f3f3f3;
top: calc(100% + 8px);
background-color: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
min-width: 150px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 180px;
z-index: 101;
overflow: hidden;
a {
display: block;
padding: 0.5rem 1rem;
padding: 0.75rem 1rem;
color: var(--text-color);
text-decoration: none;
transition: background-color 0.2s ease;
&:hover {
background-color: #f5f5f5;
@ -207,10 +341,53 @@ const navigateToGroups = () => {
}
}
.dropdown-header {
padding: 0.5rem 1rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color);
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #e0e0e0;
}
.dropdown-user-info {
padding: 0.75rem 1rem;
border-bottom: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
strong {
font-weight: 500;
}
small {
font-size: 0.8em;
opacity: 0.7;
}
}
.language-option.active {
background-color: rgba(255, 123, 84, 0.1);
color: var(--primary);
font-weight: 500;
}
.dropdown-fade-enter-active,
.dropdown-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.dropdown-fade-enter-from,
.dropdown-fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.page-container {
flex-grow: 1;
padding-bottom: calc(var(--footer-height) + 1rem); // Space for fixed footer
padding-bottom: calc(var(--footer-height) + 1rem);
}
.app-footer {
@ -222,6 +399,7 @@ const navigateToGroups = () => {
left: 0;
right: 0;
z-index: 100;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
}
.tabs {
@ -235,19 +413,43 @@ const navigateToGroups = () => {
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-color);
color: #757575;
text-decoration: none;
font-size: 0.8rem;
padding: 0.5rem 0;
border-bottom: 2px solid transparent;
gap: 4px;
transition: background-color 0.2s ease, color 0.2s ease;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 3px;
background-color: var(--primary);
transition: width 0.3s ease;
}
&.active {
color: var(--primary);
&::after {
width: 50%;
}
}
&:hover:not(.active) {
color: var(--primary);
}
.material-icons {
font-size: 24px;
}
.tab-text {
display: none;
font-size: 0.75rem;
}
@media (min-width: 768px) {
@ -255,17 +457,8 @@ const navigateToGroups = () => {
gap: 8px;
.tab-text {
display: inline;
font-size: 0.9rem;
}
}
&.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
&:hover {
background-color: #f0f0f0;
}
}
</style>

View File

@ -5,41 +5,26 @@ import { BrowserTracing } from '@sentry/tracing'
import App from './App.vue'
import router from './router'
import { createI18n } from 'vue-i18n'
import enMessages from './i18n/en.json' // Import en.json directly
import enMessages from './i18n/en.json'
import deMessages from './i18n/de.json'
import frMessages from './i18n/fr.json'
import esMessages from './i18n/es.json'
import nlMessages from './i18n/nl.json'
// Global styles
import './assets/main.scss'
// API client (from your axios boot file)
import { api, globalAxios } from '@/services/api' // Renamed from boot/axios to services/api
import { api, globalAxios } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
// Vue I18n setup (from your i18n boot file)
// // export type MessageLanguages = keyof typeof messages;
// // export type MessageSchema = (typeof messages)['en-US'];
// // export type MessageLanguages = keyof typeof messages;
// // export type MessageSchema = (typeof messages)['en-US'];
// // declare module 'vue-i18n' {
// // export interface DefineLocaleMessage extends MessageSchema {}
// // // eslint-disable-next-line @typescript-eslint/no-empty-object-type
// // export interface DefineDateTimeFormat {}
// // // eslint-disable-next-line @typescript-eslint/no-empty-object-type
// // export interface DefineNumberFormat {}
// // }
const i18n = createI18n({
legacy: false, // Recommended for Vue 3
locale: 'en', // Default locale
fallbackLocale: 'en', // Fallback locale
legacy: false,
locale: 'en',
fallbackLocale: 'en',
messages: {
en: enMessages,
de: deMessages,
fr: frMessages,
es: esMessages,
nl: nlMessages,
},
})
@ -47,7 +32,6 @@ const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
// Initialize Sentry
Sentry.init({
app,
dsn: import.meta.env.VITE_SENTRY_DSN,
@ -57,27 +41,21 @@ Sentry.init({
tracingOrigins: ['localhost', /^\//],
}),
],
// Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampleRate: 1.0,
// Set environment
environment: import.meta.env.MODE,
})
// Initialize auth state before mounting the app
const authStore = useAuthStore()
if (authStore.accessToken) {
authStore.fetchCurrentUser().catch((error) => {
console.error('Failed to initialize current user state:', error)
// The fetchCurrentUser action handles token clearing on failure.
})
}
app.use(router)
app.use(i18n)
// Make API instance globally available (optional, prefer provide/inject or store)
app.config.globalProperties.$api = api
app.config.globalProperties.$axios = globalAxios // The original axios instance if needed
app.config.globalProperties.$axios = globalAxios
app.mount('#app')

View File

@ -15,7 +15,9 @@
<form v-else @submit.prevent="onSubmitProfile">
<!-- Profile Section -->
<VCard class="mb-3">
<template #header><VHeading level="3">{{ $t('accountPage.profileSection.header') }}</VHeading></template>
<template #header>
<VHeading level="3">{{ $t('accountPage.profileSection.header') }}</VHeading>
</template>
<div class="flex flex-wrap" style="gap: 1rem;">
<VFormField :label="$t('accountPage.profileSection.nameLabel')" class="flex-grow">
<VInput id="profileName" v-model="profile.name" required />
@ -35,7 +37,9 @@
<!-- Password Section -->
<form @submit.prevent="onChangePassword">
<VCard class="mb-3">
<template #header><VHeading level="3">{{ $t('accountPage.passwordSection.header') }}</VHeading></template>
<template #header>
<VHeading level="3">{{ $t('accountPage.passwordSection.header') }}</VHeading>
</template>
<div class="flex flex-wrap" style="gap: 1rem;">
<VFormField :label="$t('accountPage.passwordSection.currentPasswordLabel')" class="flex-grow">
<VInput type="password" id="currentPassword" v-model="password.current" required />
@ -54,28 +58,33 @@
<!-- Notifications Section -->
<VCard>
<template #header><VHeading level="3">{{ $t('accountPage.notificationsSection.header') }}</VHeading></template>
<template #header>
<VHeading level="3">{{ $t('accountPage.notificationsSection.header') }}</VHeading>
</template>
<VList class="preference-list">
<VListItem class="preference-item">
<div class="preference-label">
<span>{{ $t('accountPage.notificationsSection.emailNotificationsLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.emailNotificationsDescription') }}</small>
</div>
<VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.emailNotificationsLabel')" id="emailNotificationsToggle" />
<VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange"
:label="$t('accountPage.notificationsSection.emailNotificationsLabel')" id="emailNotificationsToggle" />
</VListItem>
<VListItem class="preference-item">
<div class="preference-label">
<span>{{ $t('accountPage.notificationsSection.listUpdatesLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.listUpdatesDescription') }}</small>
</div>
<VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.listUpdatesLabel')" id="listUpdatesToggle"/>
<VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange"
:label="$t('accountPage.notificationsSection.listUpdatesLabel')" id="listUpdatesToggle" />
</VListItem>
<VListItem class="preference-item">
<div class="preference-label">
<span>{{ $t('accountPage.notificationsSection.groupActivitiesLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.groupActivitiesDescription') }}</small>
</div>
<VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.groupActivitiesLabel')" id="groupActivitiesToggle"/>
<VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange"
:label="$t('accountPage.notificationsSection.groupActivitiesLabel')" id="groupActivitiesToggle" />
</VListItem>
</VList>
</VCard>
@ -83,9 +92,10 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onMounted, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { useAuthStore } from '@/stores/auth';
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Assuming path
import { useNotificationStore } from '@/stores/notifications';
import VHeading from '@/components/valerie/VHeading.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
@ -130,6 +140,8 @@ const preferences = ref<Preferences>({
groupActivities: true,
});
const authStore = useAuthStore();
const fetchProfile = async () => {
loading.value = true;
error.value = null;

View File

@ -41,7 +41,11 @@ onMounted(async () => {
throw new Error(t('authCallbackPage.errors.noTokenProvided'));
}
await authStore.setTokens({ access_token: tokenToUse, refresh_token: refreshToken });
authStore.setTokens({ access_token: tokenToUse, refresh_token: refreshToken });
// Fetch user data after setting tokens
await authStore.fetchCurrentUser();
notificationStore.addNotification({ message: t('loginPage.notifications.loginSuccess'), type: 'success' });
router.push('/');
} catch (err) {

View File

@ -0,0 +1,65 @@
<template>
<div>
<h1>Category Management</h1>
<CategoryForm v-if="showForm" :category="selectedCategory" :loading="loading" @submit="handleFormSubmit"
@cancel="cancelForm" />
<div v-else>
<button @click="showCreateForm">Create Category</button>
<ul v-if="categories.length">
<li v-for="category in categories" :key="category.id">
{{ category.name }}
<button @click="showEditForm(category)">Edit</button>
<button @click="handleDelete(category.id)" :disabled="loading">Delete</button>
</li>
</ul>
<p v-else-if="!loading">No categories found.</p>
</div>
<p v-if="loading">Loading...</p>
<p v-if="error">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useCategoryStore, type Category } from '../stores/categoryStore';
import CategoryForm from '../components/CategoryForm.vue';
import { storeToRefs } from 'pinia';
const categoryStore = useCategoryStore();
const { categories, loading, error } = storeToRefs(categoryStore);
const showForm = ref(false);
const selectedCategory = ref<Category | null>(null);
onMounted(() => {
categoryStore.fetchCategories();
});
const showCreateForm = () => {
selectedCategory.value = null;
showForm.value = true;
};
const showEditForm = (category: Category) => {
selectedCategory.value = category;
showForm.value = true;
};
const cancelForm = () => {
showForm.value = false;
selectedCategory.value = null;
};
const handleFormSubmit = async (data: { name: string }) => {
if (selectedCategory.value) {
await categoryStore.updateCategory(selectedCategory.value.id, data);
} else {
await categoryStore.createCategory(data);
}
cancelForm();
};
const handleDelete = async (id: number) => {
await categoryStore.deleteCategory(id);
};
</script>

View File

@ -1,22 +1,23 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { format, startOfDay, isEqual, isToday as isTodayDate } from 'date-fns'
import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate } from '../types/chore'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate, ChoreAssignment, ChoreHistory, ChoreWithCompletion } from '../types/chore'
import { groupService } from '../services/groupService'
import { useStorage } from '@vueuse/core'
import ChoreItem from '@/components/ChoreItem.vue';
import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore';
import { storeToRefs } from 'pinia';
import { useAuthStore } from '@/stores/auth';
const { t } = useI18n()
const props = defineProps<{ groupId?: number | string }>();
// Types
interface ChoreWithCompletion extends Chore {
current_assignment_id: number | null;
is_completed: boolean;
completed_at: string | null;
updating: boolean;
}
// ChoreWithCompletion is now imported from ../types/chore
interface ChoreFormData {
name: string;
@ -26,6 +27,7 @@ interface ChoreFormData {
next_due_date: string;
type: 'personal' | 'group';
group_id: number | undefined;
parent_chore_id?: number | null;
}
const notificationStore = useNotificationStore()
@ -35,8 +37,14 @@ const chores = ref<ChoreWithCompletion[]>([])
const groups = ref<{ id: number, name: string }[]>([])
const showChoreModal = ref(false)
const showDeleteDialog = ref(false)
const showChoreDetailModal = ref(false)
const showHistoryModal = ref(false)
const isEditing = ref(false)
const selectedChore = ref<ChoreWithCompletion | null>(null)
const selectedChoreHistory = ref<ChoreHistory[]>([])
const selectedChoreAssignments = ref<ChoreAssignment[]>([])
const loadingHistory = ref(false)
const loadingAssignments = ref(false)
const cachedChores = useStorage<ChoreWithCompletion[]>('cached-chores-v2', [])
const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0)
@ -50,11 +58,26 @@ const initialChoreFormState: ChoreFormData = {
next_due_date: format(new Date(), 'yyyy-MM-dd'),
type: 'personal',
group_id: undefined,
parent_chore_id: null,
}
const choreForm = ref({ ...initialChoreFormState })
const isLoading = ref(true)
const authStore = useAuthStore();
const { isGuest } = storeToRefs(authStore);
const timeEntryStore = useTimeEntryStore();
const { timeEntries, loading: timeEntryLoading, error: timeEntryError } = storeToRefs(timeEntryStore);
const activeTimer = computed(() => {
for (const assignmentId in timeEntries.value) {
const entry = timeEntries.value[assignmentId].find(te => !te.end_time);
if (entry) return entry;
}
return null;
});
const loadChores = async () => {
const now = Date.now();
if (cachedChores.value && cachedChores.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
@ -71,8 +94,10 @@ const loadChores = async () => {
return {
...c,
current_assignment_id: currentAssignment?.id ?? null,
is_completed: currentAssignment?.is_complete ?? c.is_completed ?? false,
completed_at: currentAssignment?.completed_at ?? c.completed_at ?? null,
is_completed: currentAssignment?.is_complete ?? false,
completed_at: currentAssignment?.completed_at ?? null,
assigned_user_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown',
completed_by_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown',
updating: false,
}
});
@ -96,8 +121,16 @@ const loadGroups = async () => {
}
}
const loadTimeEntries = async () => {
chores.value.forEach(chore => {
if (chore.current_assignment_id) {
timeEntryStore.fetchTimeEntriesForAssignment(chore.current_assignment_id);
}
});
};
onMounted(() => {
loadChores()
loadChores().then(loadTimeEntries);
loadGroups()
})
@ -113,13 +146,24 @@ const getChoreSubtext = (chore: ChoreWithCompletion): string => {
if (chore.is_completed && chore.completed_at) {
const completedDate = new Date(chore.completed_at);
if (isTodayDate(completedDate)) {
return t('choresPage.completedToday');
return t('choresPage.completedToday') + (chore.completed_by_name ? ` by ${chore.completed_by_name}` : '');
}
return t('choresPage.completedOn', { date: format(completedDate, 'd MMM') });
const timeAgo = formatDistanceToNow(completedDate, { addSuffix: true });
return `Completed ${timeAgo}` + (chore.completed_by_name ? ` by ${chore.completed_by_name}` : '');
}
const parts: string[] = [];
// Show who it's assigned to if there's an assignment
if (chore.current_assignment_id && chore.assigned_user_name) {
parts.push(`Assigned to ${chore.assigned_user_name}`);
}
// Show creator info for group chores
if (chore.type === 'group' && chore.creator) {
parts.push(`Created by ${chore.creator.name || chore.creator.email}`);
}
if (chore.frequency && chore.frequency !== 'one_time') {
const freqOption = frequencyOptions.value.find(f => f.value === chore.frequency);
if (freqOption) {
@ -141,22 +185,63 @@ const getChoreSubtext = (chore: ChoreWithCompletion): string => {
return parts.join(' · ');
};
const groupedChores = computed(() => {
if (!chores.value) return []
const choresByDate = chores.value.reduce((acc, chore) => {
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd')
if (!acc[dueDate]) {
acc[dueDate] = []
const filteredChores = computed(() => {
if (props.groupId) {
return chores.value.filter(
c => c.type === 'group' && String(c.group_id) === String(props.groupId)
);
}
acc[dueDate].push(chore)
return acc
}, {} as Record<string, ChoreWithCompletion[]>)
return chores.value;
});
const availableParentChores = computed(() => {
return chores.value.filter(c => {
// A chore cannot be its own parent
if (isEditing.value && selectedChore.value && c.id === selectedChore.value.id) {
return false;
}
// A chore that is already a subtask cannot be a parent
if (c.parent_chore_id) {
return false;
}
// If a group is selected, only show chores from that group or personal chores
if (choreForm.value.group_id) {
return c.group_id === choreForm.value.group_id || c.type === 'personal';
}
// If no group is selected, only show personal chores that are not in a group
return c.type === 'personal' && !c.group_id;
});
});
const groupedChores = computed(() => {
if (!filteredChores.value) return [];
const choreMap = new Map<number, ChoreWithCompletion>();
filteredChores.value.forEach(chore => {
choreMap.set(chore.id, { ...chore, child_chores: [] });
});
const rootChores: ChoreWithCompletion[] = [];
choreMap.forEach(chore => {
if (chore.parent_chore_id && choreMap.has(chore.parent_chore_id)) {
choreMap.get(chore.parent_chore_id)?.child_chores?.push(chore);
} else {
rootChores.push(chore);
}
});
const choresByDate = rootChores.reduce((acc, chore) => {
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd');
if (!acc[dueDate]) {
acc[dueDate] = [];
}
acc[dueDate].push(chore);
return acc;
}, {} as Record<string, ChoreWithCompletion[]>);
return Object.keys(choresByDate)
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
.map(dateStr => {
// Create a new Date object and ensure it's interpreted as local time, not UTC
const dateParts = dateStr.split('-').map(Number);
const date = new Date(dateParts[0], dateParts[1] - 1, dateParts[2]);
return {
@ -167,9 +252,9 @@ const groupedChores = computed(() => {
...chore,
subtext: getChoreSubtext(chore)
}))
}
};
});
});
})
const formatDateHeader = (date: Date) => {
const today = startOfDay(new Date())
@ -189,6 +274,10 @@ const resetChoreForm = () => {
const openCreateChoreModal = () => {
resetChoreForm()
if (props.groupId) {
choreForm.value.type = 'group';
choreForm.value.group_id = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
}
showChoreModal.value = true
}
@ -203,6 +292,7 @@ const openEditChoreModal = (chore: ChoreWithCompletion) => {
next_due_date: chore.next_due_date,
type: chore.type,
group_id: chore.group_id ?? undefined,
parent_chore_id: chore.parent_chore_id,
}
showChoreModal.value = true
}
@ -306,11 +396,101 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
chore.updating = false;
}
};
const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
selectedChore.value = chore;
showChoreDetailModal.value = true;
// Load assignments for this chore
loadingAssignments.value = true;
try {
selectedChoreAssignments.value = await choreService.getChoreAssignments(chore.id);
} catch (error) {
console.error('Failed to load chore assignments:', error);
notificationStore.addNotification({
message: 'Failed to load chore assignments.',
type: 'error'
});
} finally {
loadingAssignments.value = false;
}
};
const openHistoryModal = async (chore: ChoreWithCompletion) => {
selectedChore.value = chore;
showHistoryModal.value = true;
// Load history for this chore
loadingHistory.value = true;
try {
selectedChoreHistory.value = await choreService.getChoreHistory(chore.id);
} catch (error) {
console.error('Failed to load chore history:', error);
notificationStore.addNotification({
message: 'Failed to load chore history.',
type: 'error'
});
} finally {
loadingHistory.value = false;
}
};
const formatHistoryEntry = (entry: ChoreHistory) => {
const timestamp = format(parseISO(entry.timestamp), 'MMM d, h:mm a');
const user = entry.changed_by_user?.name || entry.changed_by_user?.email || 'System';
switch (entry.event_type) {
case 'created':
return `${timestamp} - ${user} created this chore`;
case 'updated':
return `${timestamp} - ${user} updated this chore`;
case 'deleted':
return `${timestamp} - ${user} deleted this chore`;
case 'assigned':
return `${timestamp} - ${user} assigned this chore`;
case 'completed':
return `${timestamp} - ${user} completed this chore`;
case 'reopened':
return `${timestamp} - ${user} reopened this chore`;
default:
return `${timestamp} - ${user} performed action: ${entry.event_type}`;
}
};
const getDueDateStatus = (chore: ChoreWithCompletion) => {
if (chore.is_completed) return 'completed';
const today = startOfDay(new Date());
const dueDate = startOfDay(new Date(chore.next_due_date));
if (dueDate < today) return 'overdue';
if (isEqual(dueDate, today)) return 'due-today';
return 'upcoming';
};
const startTimer = async (chore: ChoreWithCompletion) => {
if (chore.current_assignment_id) {
await timeEntryStore.startTimeEntry(chore.current_assignment_id);
}
};
const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
if (chore.current_assignment_id) {
await timeEntryStore.stopTimeEntry(chore.current_assignment_id, timeEntryId);
}
};
</script>
<template>
<div class="container">
<header class="flex justify-between items-center">
<div v-if="isGuest" class="guest-banner">
<p>
You are using a guest account.
<router-link to="/auth/signup">Sign up</router-link>
to save your data permanently.
</p>
</div>
<header v-if="!props.groupId" class="flex justify-between items-center">
<h1 style="margin-block-start: 0;">{{ t('choresPage.title') }}</h1>
<button class="btn btn-primary" @click="openCreateChoreModal">
{{ t('choresPage.addChore', '+') }}
@ -338,28 +518,11 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
<h2 class="date-header">{{ formatDateHeader(group.date) }}</h2>
<div class="neo-item-list-container">
<ul class="neo-item-list">
<li v-for="chore in group.chores" :key="chore.id" class="neo-list-item">
<div class="neo-item-content">
<label class="neo-checkbox-label">
<input type="checkbox" :checked="chore.is_completed" @change="toggleCompletion(chore)">
<div class="checkbox-content">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
{{ chore.name }}
</span>
<span v-if="chore.subtext" class="item-time">{{ chore.subtext }}</span>
</div>
</label>
<div class="neo-item-actions">
<button class="btn btn-sm btn-neutral" @click="openEditChoreModal(chore)">
{{ t('choresPage.edit', 'Edit') }}
</button>
<button class="btn btn-sm btn-danger" @click="confirmDelete(chore)">
{{ t('choresPage.delete', 'Delete') }}
</button>
</div>
</div>
</li>
<ChoreItem v-for="chore in group.chores" :key="chore.id" :chore="chore"
:time-entries="chore.current_assignment_id ? timeEntries[chore.current_assignment_id] || [] : []"
:active-timer="activeTimer" @toggle-completion="toggleCompletion" @edit="openEditChoreModal"
@delete="confirmDelete" @open-details="openChoreDetailModal" @open-history="openHistoryModal"
@start-timer="startTimer" @stop-timer="stopTimer" />
</ul>
</div>
</div>
@ -427,6 +590,16 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="parent-chore">{{ t('choresPage.form.parentChore', 'Parent Chore')
}}</label>
<select id="parent-chore" v-model="choreForm.parent_chore_id" class="form-input">
<option :value="null">None</option>
<option v-for="parent in availableParentChores" :key="parent.id" :value="parent.id">
{{ parent.name }}
</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
@ -460,10 +633,137 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
</div>
</div>
</div>
<!-- Chore Detail Modal -->
<div v-if="showChoreDetailModal" class="modal-backdrop open" @click.self="showChoreDetailModal = false">
<div class="modal-container detail-modal">
<div class="modal-header">
<h3>{{ selectedChore?.name }}</h3>
<button type="button" @click="showChoreDetailModal = false" class="close-button">
&times;
</button>
</div>
<div class="modal-body" v-if="selectedChore">
<div class="detail-section">
<h4>Details</h4>
<div class="detail-grid">
<div class="detail-item">
<span class="label">Type:</span>
<span class="value">{{ selectedChore.type === 'group' ? 'Group' : 'Personal' }}</span>
</div>
<div class="detail-item">
<span class="label">Created by:</span>
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
}}</span>
</div>
<div class="detail-item">
<span class="label">Due date:</span>
<span class="value">{{ format(new Date(selectedChore.next_due_date), 'PPP') }}</span>
</div>
<div class="detail-item">
<span class="label">Frequency:</span>
<span class="value">
{{selectedChore?.frequency === 'custom' && selectedChore?.custom_interval_days
? `Every ${selectedChore.custom_interval_days} days`
: frequencyOptions.find(f => f.value === selectedChore?.frequency)?.label || selectedChore?.frequency
}}
</span>
</div>
<div v-if="selectedChore.description" class="detail-item full-width">
<span class="label">Description:</span>
<span class="value">{{ selectedChore.description }}</span>
</div>
</div>
</div>
<div class="detail-section">
<h4>Assignments</h4>
<div v-if="loadingAssignments" class="loading-spinner">Loading...</div>
<div v-else-if="selectedChoreAssignments.length === 0" class="no-data">
No assignments found for this chore.
</div>
<div v-else class="assignments-list">
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item">
<div class="assignment-main">
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
}}</span>
<span class="assignment-status" :class="{ completed: assignment.is_complete }">
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
</span>
</div>
<div class="assignment-details">
<span>Due: {{ format(new Date(assignment.due_date), 'PPP') }}</span>
<span v-if="assignment.completed_at">
Completed: {{ format(new Date(assignment.completed_at), 'PPP') }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreDetailModal = false">Close</button>
</div>
</div>
</div>
<!-- History Modal -->
<div v-if="showHistoryModal" class="modal-backdrop open" @click.self="showHistoryModal = false">
<div class="modal-container history-modal">
<div class="modal-header">
<h3>History: {{ selectedChore?.name }}</h3>
<button type="button" @click="showHistoryModal = false" class="close-button">
&times;
</button>
</div>
<div class="modal-body">
<div v-if="loadingHistory" class="loading-spinner">Loading history...</div>
<div v-else-if="selectedChoreHistory.length === 0" class="no-data">
No history found for this chore.
</div>
<div v-else class="history-list">
<div v-for="entry in selectedChoreHistory" :key="entry.id" class="history-item">
<div class="history-content">
<span class="history-text">{{ formatHistoryEntry(entry) }}</span>
<div v-if="entry.event_data && Object.keys(entry.event_data).length > 0" class="history-data">
<details>
<summary>Details</summary>
<pre>{{ JSON.stringify(entry.event_data, null, 2) }}</pre>
</details>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showHistoryModal = false">Close</button>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.guest-banner {
background-color: #fffbeb;
color: #92400e;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
border: 1px solid #fBBF24;
text-align: center;
}
.guest-banner p {
margin: 0;
}
.guest-banner a {
color: #92400e;
text-decoration: underline;
font-weight: bold;
}
.schedule-group {
margin-bottom: 2rem;
position: relative;
@ -503,6 +803,19 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
overflow: hidden;
}
/* Status-based styling */
.schedule-group:has(.status-overdue) .neo-item-list-container {
box-shadow: 6px 6px 0 #c72d2d;
}
.schedule-group:has(.status-due-today) .neo-item-list-container {
box-shadow: 6px 6px 0 #b37814;
}
.status-completed {
opacity: 0.7;
}
/* Neo-style list items from ListDetailPage */
.neo-item-list {
list-style: none;
@ -679,4 +992,199 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
transform: scaleX(1);
transform-origin: left;
}
/* New styles for enhanced UX */
.chore-main-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.chore-badges {
display: flex;
gap: 0.25rem;
}
.badge {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.badge-group {
background-color: #3b82f6;
color: white;
}
.badge-overdue {
background-color: #ef4444;
color: white;
}
.badge-due-today {
background-color: #f59e0b;
color: white;
}
.chore-description {
font-size: 0.875rem;
color: var(--dark);
opacity: 0.8;
margin-top: 0.25rem;
font-style: italic;
}
/* Modal styles */
.detail-modal .modal-container,
.history-modal .modal-container {
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
}
.detail-section {
margin-bottom: 1.5rem;
}
.detail-section h4 {
margin-bottom: 0.75rem;
font-weight: 600;
color: var(--dark);
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.25rem;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.detail-item.full-width {
grid-column: 1 / -1;
}
.detail-item .label {
font-weight: 600;
font-size: 0.875rem;
color: var(--dark);
opacity: 0.8;
}
.detail-item .value {
font-size: 0.875rem;
color: var(--dark);
}
.assignments-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.assignment-item {
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background-color: #f9fafb;
}
.assignment-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.assigned-user {
font-weight: 500;
color: var(--dark);
}
.assignment-status {
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background-color: #fbbf24;
color: white;
}
.assignment-status.completed {
background-color: #10b981;
}
.assignment-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--dark);
opacity: 0.8;
}
.history-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.history-item {
padding: 0.75rem;
border-left: 3px solid #e5e7eb;
background-color: #f9fafb;
border-radius: 0 0.25rem 0.25rem 0;
}
.history-text {
font-size: 0.875rem;
color: var(--dark);
}
.history-data {
margin-top: 0.5rem;
}
.history-data details {
font-size: 0.75rem;
}
.history-data summary {
cursor: pointer;
color: var(--primary);
font-weight: 500;
}
.history-data pre {
margin-top: 0.25rem;
padding: 0.5rem;
background-color: #f3f4f6;
border-radius: 0.25rem;
font-size: 0.75rem;
overflow-x: auto;
}
.loading-spinner {
text-align: center;
padding: 1rem;
color: var(--dark);
opacity: 0.7;
}
.no-data {
text-align: center;
padding: 1rem;
color: var(--dark);
opacity: 0.7;
font-style: italic;
}
</style>

View File

@ -12,7 +12,6 @@
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
// No script logic needed for this simple page
</script>
<style scoped>
@ -20,16 +19,16 @@ const { t } = useI18n();
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh; /* Fallback for browsers that don't support dvh */
min-height: 100vh;
min-height: 100dvh;
background-color: var(--secondary-accent); /* Light Blue */
background-color: var(--secondary-accent);
color: var(--dark);
padding: 2rem;
font-family: "Patrick Hand", cursive;
}
.error-code {
font-size: clamp(15vh, 25vw, 30vh); /* Responsive font size */
font-size: clamp(15vh, 25vw, 30vh);
font-weight: bold;
color: var(--primary);
line-height: 1;
@ -39,14 +38,16 @@ const { t } = useI18n();
.error-message {
font-size: clamp(1.5rem, 4vw, 2.5rem);
opacity: 0.8;
margin-top: -1rem; /* Adjust based on font size */
margin-top: -1rem;
margin-bottom: 2rem;
}
.btn-primary {
/* Ensure primary button styles are applied if not already by global .btn */
background-color: var(--primary);
color: var(--dark);
}
.mt-3 { margin-top: 1.5rem; }
.mt-3 {
margin-top: 1.5rem;
}
</style>

View File

@ -0,0 +1,761 @@
<template>
<div class="container">
<header v-if="!props.groupId" class="flex justify-between items-center">
<h1 style="margin-block-start: 0;">Expenses</h1>
<button @click="openCreateExpenseModal" class="btn btn-primary">
Add Expense
</button>
</header>
<div v-if="loading" class="flex justify-center">
<div class="spinner-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div v-else-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert">
<strong class="font-bold">Error:</strong>
<span class="block sm:inline">{{ error }}</span>
</div>
<div v-else-if="filteredExpenses.length === 0" class="empty-state-card">
<h3>No Expenses Yet</h3>
<p>Get started by adding your first expense!</p>
<button class="btn btn-primary" @click="openCreateExpenseModal">
Add First Expense
</button>
</div>
<div v-else class="schedule-list">
<div v-for="group in groupedExpenses" :key="group.title" class="schedule-group">
<h2 class="date-header">{{ group.title }}</h2>
<div class="neo-item-list-container">
<ul class="neo-item-list">
<li v-for="expense in group.expenses" :key="expense.id" class="neo-list-item"
:class="{ 'is-expanded': expandedExpenseId === expense.id }">
<div class="neo-item-content">
<div class="flex-grow cursor-pointer" @click="toggleExpenseDetails(expense.id)">
<span class="checkbox-text-span">{{ expense.description }}</span>
<div class="item-subtext">
Paid by {{ expense.paid_by_user?.full_name || expense.paid_by_user?.email ||
'N/A'
}}
· {{ formatCurrency(expense.total_amount, expense.currency) }}
<span :class="getStatusClass(expense.overall_settlement_status)"
class="status-badge">
{{ expense.overall_settlement_status.replace('_', ' ') }}
</span>
</div>
</div>
<div class="neo-item-actions">
<button @click.stop="openEditExpenseModal(expense)"
class="btn btn-sm btn-neutral">Edit</button>
<button @click.stop="handleDeleteExpense(expense.id)"
class="btn btn-sm btn-danger">Delete</button>
</div>
</div>
<div v-if="expandedExpenseId === expense.id"
class="w-full mt-2 pt-2 border-t border-gray-200/50 expanded-details">
<div>
<h3 class="font-semibold text-gray-700 mb-2 text-sm">Splits ({{
expense.split_type.replace('_', ' ') }})</h3>
<ul class="space-y-1">
<li v-for="split in expense.splits" :key="split.id"
class="flex justify-between items-center py-1 text-sm">
<span class="text-gray-600">{{ split.user?.full_name || split.user?.email
|| 'N/A' }} owes</span>
<span class="font-mono text-gray-800 font-semibold">{{
formatCurrency(split.owed_amount, expense.currency) }}</span>
</li>
</ul>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Create/Edit Expense Modal -->
<div v-if="showModal" class="modal-backdrop open" @click.self="closeModal">
<div class="modal-container">
<form @submit.prevent="handleFormSubmit">
<div class="modal-header">
<h3>{{ editingExpense ? 'Edit Expense' : 'Create New Expense' }}</h3>
<button type="button" @click="closeModal" class="close-button">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="description">Description</label>
<input type="text" v-model="formState.description" id="description" class="form-input"
required>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<div class="form-group">
<label for="total_amount" class="form-label">Total Amount</label>
<input type="number" step="0.01" min="0.01" v-model="formState.total_amount"
id="total_amount" class="form-input" required>
</div>
<div class="form-group">
<label for="currency" class="form-label">Currency</label>
<input type="text" v-model="formState.currency" id="currency" class="form-input"
required>
</div>
<div class="form-group">
<label for="paid_by_user_id" class="form-label">Paid By (User ID)</label>
<input type="number" v-model="formState.paid_by_user_id" id="paid_by_user_id"
class="form-input" required>
</div>
<div class="form-group">
<label for="split_type" class="form-label">Split Type</label>
<select v-model="formState.split_type" id="split_type" class="form-input" required>
<option value="EQUAL">Equal</option>
<option value="EXACT_AMOUNTS">Exact Amounts</option>
<option value="PERCENTAGE">Percentage</option>
<option value="SHARES">Shares</option>
<option value="ITEM_BASED">Item Based</option>
</select>
</div>
<div class="form-group">
<label for="group_id" class="form-label">Group ID (optional)</label>
<input type="number" v-model="formState.group_id" id="group_id" class="form-input">
</div>
<div class="form-group">
<label for="list_id" class="form-label">List ID (optional)</label>
<input type="number" v-model="formState.list_id" id="list_id" class="form-input">
</div>
</div>
<div class="form-group flex items-center mt-4">
<input type="checkbox" v-model="formState.isRecurring" id="is_recurring"
class="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
<label for="is_recurring" class="ml-2 block text-sm text-gray-900">This is a
recurring expense</label>
</div>
<!-- Placeholder for recurring pattern form -->
<div v-if="formState.isRecurring" class="md:col-span-2 p-4 bg-gray-50 rounded-md mt-2">
<p class="text-sm text-gray-500">Recurring expense options will be shown here.
</p>
</div>
<!-- Placeholder for splits input form -->
<div v-if="formState.split_type === 'EXACT_AMOUNTS' || formState.split_type === 'PERCENTAGE' || formState.split_type === 'SHARES'"
class="md:col-span-2 p-4 bg-gray-50 rounded-md mt-2">
<p class="text-sm text-gray-500">Inputs for {{ formState.split_type }} splits
will be shown here.</p>
</div>
<div v-if="formError" class="mt-3 bg-red-100 border-l-4 border-red-500 text-red-700 p-3">
<p>{{ formError }}</p>
</div>
</div>
<div class="modal-footer">
<button type="button" @click="closeModal" class="btn btn-neutral">Cancel</button>
<button type="submit" class="btn btn-primary">
{{ editingExpense ? 'Update Expense' : 'Create Expense' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive, computed } from 'vue'
import { expenseService, type CreateExpenseData, type UpdateExpenseData } from '@/services/expenseService'
const props = defineProps<{
groupId?: number | string;
}>();
// Types are kept local to this component
interface UserPublic {
id: number;
email: string;
full_name?: string;
}
interface ExpenseSplit {
id: number;
expense_id: number;
user_id: number;
owed_amount: string; // Decimal is string
share_percentage?: string;
share_units?: number;
user?: UserPublic;
created_at: string;
updated_at: string;
status: 'unpaid' | 'paid' | 'partially_paid';
paid_at?: string;
}
export type SplitType = 'EQUAL' | 'EXACT_AMOUNTS' | 'PERCENTAGE' | 'SHARES' | 'ITEM_BASED';
interface Expense {
id: number;
description: string;
total_amount: string; // Decimal is string
currency: string;
expense_date?: string;
split_type: SplitType;
list_id?: number;
group_id?: number;
item_id?: number;
paid_by_user_id: number;
is_recurring: boolean;
recurrence_pattern?: any;
created_at: string;
updated_at: string;
version: number;
created_by_user_id: number;
splits: ExpenseSplit[];
paid_by_user?: UserPublic;
overall_settlement_status: 'unpaid' | 'paid' | 'partially_paid';
next_occurrence?: string;
last_occurrence?: string;
parent_expense_id?: number;
generated_expenses: Expense[];
}
const expenses = ref<Expense[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const expandedExpenseId = ref<number | null>(null)
const showModal = ref(false)
const editingExpense = ref<Expense | null>(null)
const formError = ref<string | null>(null)
const initialFormState: CreateExpenseData = {
description: '',
total_amount: '',
currency: 'USD',
split_type: 'EQUAL',
isRecurring: false,
paid_by_user_id: 0, // Should be current user id by default
list_id: undefined,
group_id: undefined,
splits_in: [],
}
const formState = reactive<any>({ ...initialFormState })
const filteredExpenses = computed(() => {
if (props.groupId) {
const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
return expenses.value.filter(expense => expense.group_id === groupIdNum);
}
return expenses.value;
});
onMounted(async () => {
try {
loading.value = true
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to fetch expenses.'
console.error(err)
} finally {
loading.value = false
}
})
const groupedExpenses = computed(() => {
if (!filteredExpenses.value) return [];
const expensesByDate = filteredExpenses.value.reduce((acc, expense) => {
const dateKey = expense.expense_date ? new Date(expense.expense_date).toISOString().split('T')[0] : 'nodate';
if (!acc[dateKey]) {
acc[dateKey] = [];
}
acc[dateKey].push(expense);
return acc;
}, {} as Record<string, Expense[]>);
return Object.keys(expensesByDate)
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())
.map(dateStr => {
const date = dateStr === 'nodate' ? null : new Date(dateStr);
return {
date,
title: date ? formatDateHeader(date) : 'No Date',
expenses: expensesByDate[dateStr]
};
});
});
const toggleExpenseDetails = (expenseId: number) => {
expandedExpenseId.value = expandedExpenseId.value === expenseId ? null : expenseId
}
const formatDateHeader = (date: Date) => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const itemDate = new Date(date)
itemDate.setHours(0, 0, 0, 0)
const isToday = itemDate.getTime() === today.getTime()
if (isToday) {
return `Today, ${new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }).format(itemDate)}`
}
return new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }).format(itemDate);
}
const formatDate = (dateString?: string | Date) => {
if (!dateString) return 'N/A'
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric', month: 'long', day: 'numeric'
})
}
const formatCurrency = (amount: string | number, currency: string = 'USD') => {
const numericAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(numericAmount);
}
const getStatusClass = (status: string) => {
const statusMap: Record<string, string> = {
unpaid: 'status-overdue',
partially_paid: 'status-due-today',
paid: 'status-completed',
}
return statusMap[status] || 'bg-gray-100 text-gray-800'
}
const openCreateExpenseModal = () => {
editingExpense.value = null
Object.assign(formState, initialFormState)
if (props.groupId) {
formState.group_id = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
}
// TODO: Set formState.paid_by_user_id to current user's ID
// TODO: Fetch users/groups/lists for dropdowns
showModal.value = true
}
const openEditExpenseModal = (expense: Expense) => {
editingExpense.value = expense
// Map snake_case from Expense to camelCase for CreateExpenseData/UpdateExpenseData
formState.description = expense.description
formState.total_amount = expense.total_amount
formState.currency = expense.currency
formState.split_type = expense.split_type
formState.isRecurring = expense.is_recurring
formState.list_id = expense.list_id
formState.group_id = expense.group_id
formState.item_id = expense.item_id
formState.paid_by_user_id = expense.paid_by_user_id
formState.version = expense.version
// recurrencePattern and splits_in would need more complex mapping
showModal.value = true
}
const closeModal = () => {
showModal.value = false
editingExpense.value = null
formError.value = null
}
const handleFormSubmit = async () => {
formError.value = null
const data: any = { ...formState }
if (data.list_id === '' || data.list_id === null) data.list_id = undefined
if (data.group_id === '' || data.group_id === null) data.group_id = undefined
try {
if (editingExpense.value) {
const updateData: UpdateExpenseData = {
...data,
version: editingExpense.value.version,
}
const updatedExpense = (await expenseService.updateExpense(editingExpense.value.id, updateData)) as any as Expense;
const index = expenses.value.findIndex(e => e.id === updatedExpense.id)
if (index !== -1) {
expenses.value[index] = updatedExpense
}
} else {
const newExpense = (await expenseService.createExpense(data as CreateExpenseData)) as any as Expense;
expenses.value.unshift(newExpense)
}
closeModal()
// re-fetch all expenses to ensure data consistency after create/update
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
} catch (err: any) {
formError.value = err.response?.data?.detail || 'An error occurred during the operation.'
console.error(err)
}
}
const handleDeleteExpense = async (expenseId: number) => {
if (!confirm('Are you sure you want to delete this expense? This action cannot be undone.')) return
try {
// Note: The service deleteExpense could be enhanced to take a version for optimistic locking.
await expenseService.deleteExpense(expenseId)
expenses.value = expenses.value.filter(e => e.id !== expenseId)
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to delete expense.'
console.error(err)
}
}
</script>
<style scoped lang="scss">
.container {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
header {
margin-bottom: 1.5rem;
}
.schedule-list {
margin-top: 1.5rem;
}
.schedule-group {
margin-bottom: 2rem;
position: relative;
}
.date-header {
font-size: clamp(1rem, 4vw, 1.2rem);
font-weight: bold;
color: var(--dark);
text-transform: none;
letter-spacing: normal;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--dark);
position: sticky;
top: 0;
background-color: var(--light);
z-index: 10;
}
.neo-item-list-container {
border: 3px solid #111;
border-radius: 18px;
background: var(--light);
box-shadow: 6px 6px 0 #111;
overflow: hidden;
}
.neo-item-list {
list-style: none;
padding: 0.5rem 1rem;
margin: 0;
}
.neo-list-item {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
position: relative;
transition: background-color 0.2s ease;
}
.neo-list-item:hover {
background-color: #f8f8f8;
}
.neo-list-item:last-child {
border-bottom: none;
}
.neo-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.neo-item-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: auto;
.btn {
margin-left: 0.25rem;
}
}
.neo-list-item:hover .neo-item-actions {
opacity: 1;
}
.checkbox-text-span {
position: relative;
transition: color 0.4s ease, opacity 0.4s ease;
width: fit-content;
font-weight: 500;
color: var(--dark);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-subtext {
font-size: 0.8rem;
color: var(--dark);
opacity: 0.7;
margin-top: 0.2rem;
}
.status-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
font-size: 0.7rem;
border-radius: 9999px;
font-weight: 600;
text-transform: capitalize;
margin-left: 0.5rem;
}
.status-unpaid {
background-color: #fef2f2;
color: #991b1b;
}
.status-partially_paid {
background-color: #fffbeb;
color: #92400e;
}
.status-paid {
background-color: #f0fdf4;
color: #166534;
}
.is-expanded {
.expanded-details {
max-height: 500px;
/* or a suitable value */
transition: max-height 0.5s ease-in-out;
}
}
.expanded-details {
padding-left: 1.5rem;
/* Indent details */
}
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.modal-backdrop.open {
opacity: 1;
visibility: visible;
}
.modal-container {
background-color: white;
padding: 1.5rem;
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 90%;
max-width: 600px;
transform: translateY(-20px);
transition: transform 0.3s ease;
}
.modal-backdrop.open .modal-container {
transform: translateY(0);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.modal-header h3 {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
}
.modal-body {
padding-bottom: 1rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
color: #374151;
}
.form-input,
select.form-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #4f46e5;
color: white;
border: 1px solid transparent;
}
.btn-primary:hover {
background-color: #4338ca;
}
.btn-neutral {
background-color: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-neutral:hover {
background-color: #f9fafb;
}
.btn-danger {
background-color: #dc2626;
color: white;
border: 1px solid transparent;
}
.btn-danger:hover {
background-color: #b91c1c;
}
.spinner-dots {
display: flex;
justify-content: center;
align-items: center;
}
.spinner-dots span {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #4f46e5;
margin: 0 4px;
animation: spinner-grow 1.4s infinite ease-in-out both;
}
.spinner-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.spinner-dots span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes spinner-grow {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
.empty-state-card {
text-align: center;
padding: 3rem 1.5rem;
background-color: #f9fafb;
border: 2px dashed #e5e7eb;
border-radius: 0.75rem;
margin-top: 2rem;
}
.empty-state-card h3 {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.empty-state-card p {
margin-top: 0.5rem;
color: #6b7280;
}
.empty-state-card .btn {
margin-top: 1.5rem;
}
.neo-section-header {
font-weight: 900;
font-size: 1.25rem;
margin: 0;
margin-bottom: 1rem;
letter-spacing: 0.5px;
}
</style>

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