Compare commits
83 Commits
Author | SHA1 | Date | |
---|---|---|---|
6e812431c8 | |||
![]() |
448a0705d2 | ||
![]() |
7ffeae1476 | ||
5c57ac8080 | |||
![]() |
7ffd4b9a91 | ||
453ce9e45f | |||
![]() |
f49e15c05c | ||
b9434e6b56 | |||
![]() |
bbe3b3a493 | ||
![]() |
8a0457aeec | ||
a7a01b90cf | |||
![]() |
10845d2e5f | ||
![]() |
dccd7bb300 | ||
2d70850840 | |||
![]() |
3ec2ff1f89 | ||
![]() |
8afeda1df7 | ||
471c7b069d | |||
![]() |
26f589751d | ||
51474695ef | |||
![]() |
81f551a21d | ||
d13a231113 | |||
![]() |
88c9516308 | ||
![]() |
402489c928 | ||
![]() |
f20f3c960d | ||
![]() |
fb951acb72 | ||
3d2bc3846a | |||
ef2caaee56 | |||
6004911912 | |||
ef41ebb954 | |||
24a5024e88 | |||
acdf1af9b9 | |||
f3fdbc0592 | |||
1f7abcbd85 | |||
76446cf84e | |||
df08bdaf9e | |||
6a61bb8df4 | |||
e124f05e7b | |||
f60002d98e | |||
708a6280d6 | |||
20e1c2ac69 | |||
e777268643 | |||
3be38002e7 | |||
d23219fd60 | |||
088f371547 | |||
b5f16a3d0d | |||
0a6877852a | |||
d3d5f88e09 | |||
1ccd4456f6 | |||
acdb628777 | |||
463cfe070c | |||
8a98aee6c1 | |||
0a42d68853 | |||
26315cd407 | |||
8517cbee99 | |||
f882b86f05 | |||
5e79be16d3 | |||
d1b8191c8d | |||
8d3bf927b6 | |||
e62bceb955 | |||
99d06baa03 | |||
530867bb16 | |||
de5f54f970 | |||
792a7878f0 | |||
c62c0d0157 | |||
855dd852c5 | |||
028c991d91 | |||
1f7f573f64 | |||
350ccaf5d8 | |||
ca73d6ca79 | |||
d7bd69f68c | |||
fd15ed5a35 | |||
0cdc47d0d2 | |||
c90ee6b73f | |||
3c30eaeaee | |||
1907911779 | |||
cda51e34ba | |||
c7f296597e | |||
b3fd3acad9 | |||
258798846d | |||
6f69ad8fcc | |||
7a3e91a324 | |||
e43b4fe50a | |||
b37cbebf8a |
.cursor/rules
be
alembic/versions
05bf96a9e18b_add_chore_history_and_scheduling_tables.pybdf7427ccfa3_feature_updates_phase1.pyc693ade3601c_add_updated_at_and_version_to_groups.py
app
api
auth.pyconfig.pycore
crud
audit.pycategory.pychore.pyexpense.pygroup.pyhistory.pyinvite.pyitem.pylist.pyschedule.pysettlement.pysettlement_activity.pyuser.py
database.pydb
jobs
main.pymodels.pymodels
schemas
fe
package-lock.jsonpackage.json
src
assets
components
CategoryForm.vueChoreItem.vueCreateExpenseForm.vueCreateGroupModal.vueCreateListModal.vue
list-detail
valerie
config
i18n
layouts
main.tspages
32
.cursor/rules/fastapi.mdc
Normal file
32
.cursor/rules/fastapi.mdc
Normal 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 FastAPI’s 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
267
.cursor/rules/roadmap.mdc
Normal 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
37
.cursor/rules/vue.mdc
Normal 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.
|
||||
|
||||
|
||||
|
||||
|
@ -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 ###
|
91
be/alembic/versions/bdf7427ccfa3_feature_updates_phase1.py
Normal file
91
be/alembic/versions/bdf7427ccfa3_feature_updates_phase1.py
Normal 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 ###
|
@ -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 ###
|
@ -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
65
be/app/api/auth/guest.py
Normal 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
26
be/app/api/auth/jwt.py
Normal 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"],
|
||||
)
|
@ -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"
|
||||
})
|
@ -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"])
|
||||
|
75
be/app/api/v1/endpoints/categories.py
Normal file
75
be/app/api/v1/endpoints/categories.py
Normal 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)
|
@ -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
|
@ -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()
|
||||
|
@ -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)
|
@ -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))
|
46
be/app/api/v1/endpoints/history.py
Normal file
46
be/app/api/v1/endpoints/history.py
Normal 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
|
@ -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)
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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
|
@ -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()
|
11
be/app/api/v1/endpoints/users.py
Normal file
11
be/app/api/v1/endpoints/users.py
Normal 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"],
|
||||
)
|
@ -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,
|
||||
|
176
be/app/config.py
176
be/app/config.py
@ -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",
|
||||
|
@ -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
78
be/app/core/cache.py
Normal 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}")
|
@ -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
|
||||
|
||||
|
@ -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
|
@ -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
7
be/app/core/redis.py
Normal 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
|
@ -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:
|
||||
|
@ -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
77
be/app/crud/audit.py
Normal 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
38
be/app/crud/category.py
Normal 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
|
@ -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)
|
||||
|
@ -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.
|
||||
|
@ -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
82
be/app/crud/history.py
Normal 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()
|
@ -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): ...
|
@ -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
|
@ -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
103
be/app/crud/schedule.py
Normal 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
|
@ -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
|
@ -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)
|
||||
|
@ -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
|
@ -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
|
@ -1,4 +1,2 @@
|
||||
from app.database import AsyncSessionLocal
|
||||
|
||||
# Export the async session factory
|
||||
async_session = AsyncSessionLocal
|
@ -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)
|
||||
|
155
be/app/main.py
155
be/app/main.py
@ -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)
|
||||
# ------------------------------------------------------
|
224
be/app/models.py
224
be/app/models.py
@ -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
20
be/app/schemas/audit.py
Normal 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
|
@ -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
|
19
be/app/schemas/category.py
Normal file
19
be/app/schemas/category.py
Normal 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
|
@ -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()
|
||||
|
@ -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
|
@ -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
|
@ -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()
|
@ -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
|
@ -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
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -1,4 +1,3 @@
|
||||
# app/schemas/message.py
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Message(BaseModel):
|
||||
|
@ -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]
|
@ -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
|
||||
|
22
be/app/schemas/time_entry.py
Normal file
22
be/app/schemas/time_entry.py
Normal 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
8
be/app/schemas/token.py
Normal 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
|
@ -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
|
@ -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
29
fe/package-lock.json
generated
@ -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": {
|
||||
|
@ -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",
|
||||
|
@ -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 {
|
||||
|
47
fe/src/components/CategoryForm.vue
Normal file
47
fe/src/components/CategoryForm.vue
Normal 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>
|
355
fe/src/components/ChoreItem.vue
Normal file
355
fe/src/components/ChoreItem.vue
Normal 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>
|
@ -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';
|
||||
|
102
fe/src/components/CreateGroupModal.vue
Normal file
102
fe/src/components/CreateGroupModal.vue
Normal 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>
|
@ -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;
|
||||
|
142
fe/src/components/list-detail/CostSummaryDialog.vue
Normal file
142
fe/src/components/list-detail/CostSummaryDialog.vue
Normal 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>
|
384
fe/src/components/list-detail/ExpenseSection.vue
Normal file
384
fe/src/components/list-detail/ExpenseSection.vue
Normal 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) }} —
|
||||
{{ $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>
|
255
fe/src/components/list-detail/ItemsList.vue
Normal file
255
fe/src/components/list-detail/ItemsList.vue
Normal 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>
|
497
fe/src/components/list-detail/ListItem.vue
Normal file
497
fe/src/components/list-detail/ListItem.vue
Normal 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>
|
114
fe/src/components/list-detail/OcrDialog.vue
Normal file
114
fe/src/components/list-detail/OcrDialog.vue
Normal 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>
|
73
fe/src/components/list-detail/SettleShareModal.vue
Normal file
73
fe/src/components/list-detail/SettleShareModal.vue
Normal 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>
|
@ -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;
|
||||
}
|
||||
|
@ -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}`,
|
||||
},
|
||||
}
|
||||
|
@ -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 };
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -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>
|
@ -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')
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
65
fe/src/pages/CategoriesPage.vue
Normal file
65
fe/src/pages/CategoriesPage.vue
Normal 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>
|
@ -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">
|
||||
×
|
||||
</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">
|
||||
×
|
||||
</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>
|
||||
|
@ -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>
|
761
fe/src/pages/ExpensePage.vue
Normal file
761
fe/src/pages/ExpensePage.vue
Normal 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">×</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
Loading…
Reference in New Issue
Block a user