Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ def create_app(config_name='default'):
from app.routes.dashboard import dashboard_bp
app.register_blueprint(dashboard_bp)

from app.routes.progress import progress_bp
app.register_blueprint(progress_bp)

from app.routes.user_addiction import user_addiction_bp
app.register_blueprint(user_addiction_bp)

Expand Down
36 changes: 36 additions & 0 deletions backend/app/dto/progress_dto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from dataclasses import dataclass
from typing import List


@dataclass
class DayEntryDTO:
"""DTO for a single day entry in progress."""
date: str
day_of_week: str
status: str

def to_dict(self):
"""Convert DTO to dictionary for JSON serialization."""
return {
'date': self.date,
'dayOfWeek': self.day_of_week,
'status': self.status
}


@dataclass
class ProgressResponseDTO:
"""DTO for the complete progress response."""
addiction_name: str
single_day_cost: float
total_savings: float
entries: List[DayEntryDTO]

def to_dict(self):
"""Convert to dictionary for JSON serialization."""
return {
'addictionName': self.addiction_name,
'singleDayCost': self.single_day_cost,
'totalSavings': self.total_savings,
'entries': [entry.to_dict() for entry in self.entries]
}
38 changes: 38 additions & 0 deletions backend/app/routes/progress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from flask import Blueprint, jsonify, request
from app.services.progress_service import ProgressService
from app.routes.authorization import token_required

progress_bp = Blueprint('progress', __name__)

@progress_bp.route('/api/progress', methods=['GET'])
@token_required
def get_progress(current_user):
"""
Endpoint to retrieve user progress data.

Returns full progress history including addiction name, single day cost,
total savings, and all day entries from start date to today.
Dates calculated according to Europe/Warsaw timezone (can be overridden with X-Timezone header).

Args:
current_user (str): Username from JWT token

Returns:
JSON: Progress data
"""
try:
# Get timezone from header (default: Europe/Warsaw)
timezone = request.headers.get('X-Timezone', 'Europe/Warsaw')

# Get progress data
progress_dto = ProgressService.get_user_progress_data(current_user, timezone)
serialized_data = ProgressService.serialize_progress_response(progress_dto)

return jsonify(serialized_data), 200

except Exception as e:
return jsonify({
'error': True,
'message': str(e),
'code': 'PROGRESS_FETCH_ERROR'
}), 500
24 changes: 24 additions & 0 deletions backend/app/schemas/progress_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from marshmallow import Schema, fields, validate


class DayEntrySchema(Schema):
"""Schema for serializing a single day entry."""
date = fields.String(required=True, description="Date in YYYY-MM-DD format")
dayOfWeek = fields.String(required=True, description="Day of week abbreviation")
status = fields.String(
required=True,
validate=validate.OneOf(['success', 'failure', 'none']),
description="Status of the day: success, failure, or none"
)


class ProgressResponseSchema(Schema):
"""Schema for the complete progress response."""
addictionName = fields.String(required=True, description="Name of the user's addiction")
singleDayCost = fields.Float(required=True, description="Daily cost of the habit in PLN")
totalSavings = fields.Float(required=True, description="Total money saved in PLN")
entries = fields.List(
fields.Nested(DayEntrySchema),
required=True,
description="Full history of day entries"
)
154 changes: 154 additions & 0 deletions backend/app/services/progress_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
from datetime import datetime, timedelta
from typing import List
import pytz

from app import db
from app.models.user import User
from app.models.user_addiction import UserAddiction
from app.models.daily_log import DailyLog
from app.dto.progress_dto import ProgressResponseDTO, DayEntryDTO
from app.schemas.progress_schema import ProgressResponseSchema


class ProgressService:

# Polish day abbreviations mapping
DAY_ABBREVIATIONS = {
0: 'pon', # Monday
1: 'wt', # Tuesday
2: 'śr', # Wednesday
3: 'czw', # Thursday
4: 'pt', # Friday
5: 'sob', # Saturday
6: 'nd' # Sunday
}

@staticmethod
def get_user_progress_data(username: str, timezone: str = 'Europe/Warsaw') -> ProgressResponseDTO:
"""
Get progress data for a specific user.

Args:
username (str): The username
timezone (str): Timezone for date calculations (default: Europe/Warsaw)

Returns:
ProgressResponseDTO: Progress data

Raises:
Exception: If user or user addiction not found, or other errors
"""
try:
# Get user
user = User.query.filter_by(name=username).first()
if not user:
raise Exception(f"User '{username}' not found")

# Get user's addiction (assuming one active addiction per user)
user_addiction = UserAddiction.query.filter_by(user_id=user.id).first()
if not user_addiction:
raise Exception(f"No addiction found for user '{username}'")

# Get timezone
tz = pytz.timezone(timezone)

# Calculate components
addiction_name = user_addiction.addiction.name
single_day_cost = user_addiction.cost_per_day or 0.0
total_savings = ProgressService._calculate_total_savings(user_addiction)
entries = ProgressService._get_all_entries(user_addiction, tz)

return ProgressResponseDTO(
addiction_name=addiction_name,
single_day_cost=single_day_cost,
total_savings=total_savings,
entries=entries
)

except Exception as e:
raise Exception(f"Progress service error: {str(e)}")

@staticmethod
def _calculate_total_savings(user_addiction: UserAddiction) -> float:
"""
Calculate total savings based on successful days and daily cost.

Args:
user_addiction: UserAddiction instance

Returns:
float: Total savings in PLN
"""
if not user_addiction.cost_per_day:
return 0.0

# Count successful days (relapse = 0)
successful_days = db.session.query(DailyLog).filter(
DailyLog.users_addiction == user_addiction.id,
DailyLog.relapse == 0
).count()

return successful_days * user_addiction.cost_per_day

@staticmethod
def _get_all_entries(user_addiction: UserAddiction, tz: pytz.timezone) -> List[DayEntryDTO]:
"""
Get all day entries from start date to today.

Args:
user_addiction: UserAddiction instance
tz: Timezone for date calculations

Returns:
List[DayEntryDTO]: List of all day entries (newest first)
"""
today = datetime.now(tz).date()
start_date = datetime.fromtimestamp(user_addiction.start_date, tz).date()
entries = []

# Calculate all days from start to today
current_date = today

while current_date >= start_date:
date_str = current_date.strftime('%Y-%m-%d')

# Get day of week abbreviation
day_of_week = ProgressService.DAY_ABBREVIATIONS[current_date.weekday()]

# Find daily log for this date
daily_log = DailyLog.query.filter(
DailyLog.users_addiction == user_addiction.id,
DailyLog.date == date_str
).first()

# Determine status
if daily_log is None:
status = 'none'
elif daily_log.relapse == 0:
status = 'success'
else: # relapse == 1
status = 'failure'

entries.append(DayEntryDTO(
date=date_str,
day_of_week=day_of_week,
status=status
))

current_date -= timedelta(days=1)

return entries

@staticmethod
def serialize_progress_response(progress_dto: ProgressResponseDTO) -> dict:
"""
Serialize progress DTO using Marshmallow schema.

Args:
progress_dto: ProgressResponseDTO to serialize

Returns:
dict: Serialized progress data ready for JSON response
"""
schema = ProgressResponseSchema()
return schema.dump(progress_dto.to_dict())
2 changes: 1 addition & 1 deletion backend/tests/test_dashboard_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def test_get_dashboard_invalid_token(self, client):
assert response.status_code == 401
data = json.loads(response.data)
assert 'message' in data
assert 'nieprawidłowy' in data['message']
assert 'nieprawidlowy' in data['message']

def test_get_dashboard_with_custom_timezone(self, client, auth_token):
"""Test GET /api/dashboard with custom timezone header"""
Expand Down
Loading