From 75feb3982060e15c046f91ff9aaa2a34ee8829e8 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Tue, 1 Jul 2025 12:54:48 +0000 Subject: [PATCH] Update code via agent code generation --- alembic/versions/002_enhanced_features.py | 451 ++++++++++++++++++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/attendance.py | 484 ++++++++++++++++++++++ app/api/donations.py | 311 ++++++++++++++ app/api/messages.py | 413 ++++++++++++++++++ app/api/ministries.py | 325 +++++++++++++++ app/api/notifications.py | 147 +++++++ app/api/prayers.py | 427 +++++++++++++++++++ app/api/uploads.py | 68 +++ app/core/__init__.py | 0 app/core/upload.py | 71 ++++ app/db/__init__.py | 0 app/models/__init__.py | 0 app/models/attendance.py | 124 ++++++ app/models/donation.py | 106 +++++ app/models/message.py | 109 +++++ app/models/ministry.py | 99 +++++ app/models/notification.py | 67 +++ app/models/prayer.py | 112 +++++ app/models/user.py | 7 + app/schemas/__init__.py | 0 app/schemas/attendance.py | 158 +++++++ app/schemas/donation.py | 137 ++++++ app/schemas/message.py | 98 +++++ app/schemas/ministry.py | 99 +++++ app/schemas/notification.py | 52 +++ app/schemas/prayer.py | 103 +++++ app/services/notification_service.py | 164 ++++++++ main.py | 24 +- requirements.txt | 9 +- 31 files changed, 4163 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/002_enhanced_features.py delete mode 100644 app/__init__.py delete mode 100644 app/api/__init__.py create mode 100644 app/api/attendance.py create mode 100644 app/api/donations.py create mode 100644 app/api/messages.py create mode 100644 app/api/ministries.py create mode 100644 app/api/notifications.py create mode 100644 app/api/prayers.py create mode 100644 app/api/uploads.py delete mode 100644 app/core/__init__.py create mode 100644 app/core/upload.py delete mode 100644 app/db/__init__.py delete mode 100644 app/models/__init__.py create mode 100644 app/models/attendance.py create mode 100644 app/models/donation.py create mode 100644 app/models/message.py create mode 100644 app/models/ministry.py create mode 100644 app/models/notification.py create mode 100644 app/models/prayer.py delete mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/attendance.py create mode 100644 app/schemas/donation.py create mode 100644 app/schemas/message.py create mode 100644 app/schemas/ministry.py create mode 100644 app/schemas/notification.py create mode 100644 app/schemas/prayer.py create mode 100644 app/services/notification_service.py diff --git a/alembic/versions/002_enhanced_features.py b/alembic/versions/002_enhanced_features.py new file mode 100644 index 0000000..2560a1d --- /dev/null +++ b/alembic/versions/002_enhanced_features.py @@ -0,0 +1,451 @@ +"""Enhanced features migration + +Revision ID: 002 +Revises: 001 +Create Date: 2025-07-01 14:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "002" +down_revision: Union[str, None] = "001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create notifications table + op.create_table( + "notifications", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("recipient_id", sa.Integer(), nullable=False), + sa.Column("sender_id", sa.Integer(), nullable=True), + sa.Column( + "type", + sa.Enum( + "CONNECTION_REQUEST", + "CONNECTION_ACCEPTED", + "POST_LIKE", + "POST_COMMENT", + "EVENT_INVITATION", + "EVENT_REMINDER", + "PRAYER_REQUEST", + "MINISTRY_ASSIGNMENT", + "DONATION_REMINDER", + "BIRTHDAY_REMINDER", + "ANNOUNCEMENT", + name="notificationtype", + ), + nullable=False, + ), + sa.Column("title", sa.String(), nullable=False), + sa.Column("message", sa.Text(), nullable=False), + sa.Column("data", sa.Text(), nullable=True), + sa.Column("is_read", sa.Boolean(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("read_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["recipient_id"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["sender_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_notifications_id"), "notifications", ["id"], unique=False) + + # Create notification_settings table + op.create_table( + "notification_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("email_notifications", sa.Boolean(), nullable=True), + sa.Column("push_notifications", sa.Boolean(), nullable=True), + sa.Column("connection_requests", sa.Boolean(), nullable=True), + sa.Column("post_interactions", sa.Boolean(), nullable=True), + sa.Column("event_reminders", sa.Boolean(), nullable=True), + sa.Column("prayer_requests", sa.Boolean(), nullable=True), + sa.Column("ministry_updates", sa.Boolean(), nullable=True), + sa.Column("donation_reminders", sa.Boolean(), nullable=True), + sa.Column("birthday_reminders", sa.Boolean(), nullable=True), + sa.Column("announcements", sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_notification_settings_id"), + "notification_settings", + ["id"], + unique=False, + ) + + # Create ministries table + op.create_table( + "ministries", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("vision", sa.Text(), nullable=True), + sa.Column("mission", sa.Text(), nullable=True), + sa.Column("meeting_day", sa.String(), nullable=True), + sa.Column("meeting_time", sa.String(), nullable=True), + sa.Column("meeting_location", sa.String(), nullable=True), + sa.Column("leader_id", sa.Integer(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["leader_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_ministries_id"), "ministries", ["id"], unique=False) + + # Create ministry_members table + op.create_table( + "ministry_members", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("ministry_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column( + "role", + sa.Enum("LEADER", "CO_LEADER", "MEMBER", "VOLUNTEER", name="ministryrole"), + nullable=True, + ), + sa.Column("joined_at", sa.DateTime(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint( + ["ministry_id"], + ["ministries.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_ministry_members_id"), "ministry_members", ["id"], unique=False + ) + + # Create ministry_activities table + op.create_table( + "ministry_activities", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("ministry_id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("activity_date", sa.DateTime(), nullable=False), + sa.Column("location", sa.String(), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["created_by"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["ministry_id"], + ["ministries.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_ministry_activities_id"), "ministry_activities", ["id"], unique=False + ) + + # Create donations table + op.create_table( + "donations", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("donor_id", sa.Integer(), nullable=False), + sa.Column("amount", sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column("currency", sa.String(), nullable=True), + sa.Column( + "donation_type", + sa.Enum( + "TITHE", + "OFFERING", + "SPECIAL_OFFERING", + "BUILDING_FUND", + "MISSIONS", + "CHARITY", + "OTHER", + name="donationtype", + ), + nullable=False, + ), + sa.Column( + "payment_method", + sa.Enum( + "CASH", + "CHECK", + "CARD", + "BANK_TRANSFER", + "MOBILE_MONEY", + "ONLINE", + name="paymentmethod", + ), + nullable=False, + ), + sa.Column("reference_number", sa.String(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("is_anonymous", sa.Boolean(), nullable=True), + sa.Column("is_recurring", sa.Boolean(), nullable=True), + sa.Column("recurring_frequency", sa.String(), nullable=True), + sa.Column("next_due_date", sa.DateTime(), nullable=True), + sa.Column("donation_date", sa.DateTime(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["donor_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_donations_id"), "donations", ["id"], unique=False) + + # Create conversations table + op.create_table( + "conversations", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("participant_1_id", sa.Integer(), nullable=False), + sa.Column("participant_2_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["participant_1_id"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["participant_2_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_conversations_id"), "conversations", ["id"], unique=False) + + # Create messages table + op.create_table( + "messages", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("conversation_id", sa.Integer(), nullable=False), + sa.Column("sender_id", sa.Integer(), nullable=False), + sa.Column("receiver_id", sa.Integer(), nullable=False), + sa.Column( + "message_type", + sa.Enum("TEXT", "IMAGE", "FILE", name="messagetype"), + nullable=True, + ), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("file_url", sa.String(), nullable=True), + sa.Column("is_read", sa.Boolean(), nullable=True), + sa.Column("read_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["conversation_id"], + ["conversations.id"], + ), + sa.ForeignKeyConstraint( + ["receiver_id"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["sender_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_messages_id"), "messages", ["id"], unique=False) + + # Create prayer_requests table + op.create_table( + "prayer_requests", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("requester_id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column( + "category", + sa.Enum( + "HEALTH", + "FAMILY", + "FINANCES", + "RELATIONSHIPS", + "WORK", + "SPIRITUAL", + "CHURCH", + "WORLD", + "OTHER", + name="prayercategory", + ), + nullable=True, + ), + sa.Column( + "privacy_level", + sa.Enum( + "PUBLIC", + "CONNECTIONS_ONLY", + "MINISTRY_ONLY", + "PRIVATE", + name="prayerprivacylevel", + ), + nullable=True, + ), + sa.Column( + "status", + sa.Enum("ACTIVE", "ANSWERED", "CLOSED", name="prayerstatus"), + nullable=True, + ), + sa.Column("ministry_id", sa.Integer(), nullable=True), + sa.Column("is_anonymous", sa.Boolean(), nullable=True), + sa.Column("answer_description", sa.Text(), nullable=True), + sa.Column("answered_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["ministry_id"], + ["ministries.id"], + ), + sa.ForeignKeyConstraint( + ["requester_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_prayer_requests_id"), "prayer_requests", ["id"], unique=False + ) + + # Create prayers table + op.create_table( + "prayers", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("prayer_request_id", sa.Integer(), nullable=False), + sa.Column("prayer_warrior_id", sa.Integer(), nullable=False), + sa.Column("message", sa.Text(), nullable=True), + sa.Column("prayed_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["prayer_request_id"], + ["prayer_requests.id"], + ), + sa.ForeignKeyConstraint( + ["prayer_warrior_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_prayers_id"), "prayers", ["id"], unique=False) + + # Create services table + op.create_table( + "services", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column( + "service_type", + sa.Enum( + "SUNDAY_SERVICE", + "BIBLE_STUDY", + "PRAYER_MEETING", + "YOUTH_SERVICE", + "WOMEN_MEETING", + "MEN_MEETING", + "SPECIAL_SERVICE", + "OTHER", + name="servicetype", + ), + nullable=False, + ), + sa.Column("service_date", sa.Date(), nullable=False), + sa.Column("start_time", sa.DateTime(), nullable=False), + sa.Column("end_time", sa.DateTime(), nullable=True), + sa.Column("location", sa.String(), nullable=True), + sa.Column("minister_id", sa.Integer(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["created_by"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["minister_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_services_id"), "services", ["id"], unique=False) + + # Create attendance_records table + op.create_table( + "attendance_records", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("service_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column( + "status", + sa.Enum("PRESENT", "ABSENT", "LATE", "EXCUSED", name="attendancestatus"), + nullable=True, + ), + sa.Column("check_in_time", sa.DateTime(), nullable=True), + sa.Column("check_out_time", sa.DateTime(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("recorded_by", sa.Integer(), nullable=False), + sa.Column("recorded_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["recorded_by"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["service_id"], + ["services.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_attendance_records_id"), "attendance_records", ["id"], unique=False + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_attendance_records_id"), table_name="attendance_records") + op.drop_table("attendance_records") + op.drop_index(op.f("ix_services_id"), table_name="services") + op.drop_table("services") + op.drop_index(op.f("ix_prayers_id"), table_name="prayers") + op.drop_table("prayers") + op.drop_index(op.f("ix_prayer_requests_id"), table_name="prayer_requests") + op.drop_table("prayer_requests") + op.drop_index(op.f("ix_messages_id"), table_name="messages") + op.drop_table("messages") + op.drop_index(op.f("ix_conversations_id"), table_name="conversations") + op.drop_table("conversations") + op.drop_index(op.f("ix_donations_id"), table_name="donations") + op.drop_table("donations") + op.drop_index(op.f("ix_ministry_activities_id"), table_name="ministry_activities") + op.drop_table("ministry_activities") + op.drop_index(op.f("ix_ministry_members_id"), table_name="ministry_members") + op.drop_table("ministry_members") + op.drop_index(op.f("ix_ministries_id"), table_name="ministries") + op.drop_table("ministries") + op.drop_index( + op.f("ix_notification_settings_id"), table_name="notification_settings" + ) + op.drop_table("notification_settings") + op.drop_index(op.f("ix_notifications_id"), table_name="notifications") + op.drop_table("notifications") diff --git a/app/__init__.py b/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/api/__init__.py b/app/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/api/attendance.py b/app/api/attendance.py new file mode 100644 index 0000000..b19e780 --- /dev/null +++ b/app/api/attendance.py @@ -0,0 +1,484 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import func +from typing import List, Optional +from datetime import datetime, date +from app.db.base import get_db +from app.models.user import User +from app.models.attendance import ( + Service, + AttendanceRecord, + AttendanceGoal, + ServiceType, + AttendanceStatus, +) +from app.schemas.attendance import ( + ServiceCreate, + ServiceResponse, + AttendanceRecordCreate, + AttendanceRecordUpdate, + AttendanceRecordResponse, + AttendanceGoalCreate, + AttendanceGoalResponse, + AttendanceStats, +) +from app.api.auth import get_current_user + +router = APIRouter() + +# Services + + +@router.post("/services", response_model=ServiceResponse) +def create_service( + service: ServiceCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create a new service (admin only)""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + db_service = Service(**service.dict(), created_by=current_user.id) + db.add(db_service) + db.commit() + db.refresh(db_service) + + db_service.creator_name = f"{current_user.first_name} {current_user.last_name}" + if db_service.minister: + db_service.minister_name = ( + f"{db_service.minister.first_name} {db_service.minister.last_name}" + ) + db_service.attendance_count = 0 + + return db_service + + +@router.get("/services", response_model=List[ServiceResponse]) +def get_services( + skip: int = 0, + limit: int = 100, + service_type: Optional[ServiceType] = None, + start_date: Optional[date] = None, + end_date: Optional[date] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get services""" + query = db.query(Service).filter(Service.is_active) + + if service_type: + query = query.filter(Service.service_type == service_type) + if start_date: + query = query.filter(Service.service_date >= start_date) + if end_date: + query = query.filter(Service.service_date <= end_date) + + services = ( + query.order_by(Service.service_date.desc()).offset(skip).limit(limit).all() + ) + + for service in services: + service.creator_name = ( + f"{service.creator.first_name} {service.creator.last_name}" + ) + if service.minister: + service.minister_name = ( + f"{service.minister.first_name} {service.minister.last_name}" + ) + + service.attendance_count = ( + db.query(AttendanceRecord) + .filter( + AttendanceRecord.service_id == service.id, + AttendanceRecord.status.in_( + [AttendanceStatus.PRESENT, AttendanceStatus.LATE] + ), + ) + .count() + ) + + return services + + +@router.get("/services/{service_id}", response_model=ServiceResponse) +def get_service( + service_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get specific service""" + service = db.query(Service).filter(Service.id == service_id).first() + + if not service: + raise HTTPException(status_code=404, detail="Service not found") + + service.creator_name = f"{service.creator.first_name} {service.creator.last_name}" + if service.minister: + service.minister_name = ( + f"{service.minister.first_name} {service.minister.last_name}" + ) + + service.attendance_count = ( + db.query(AttendanceRecord) + .filter( + AttendanceRecord.service_id == service.id, + AttendanceRecord.status.in_( + [AttendanceStatus.PRESENT, AttendanceStatus.LATE] + ), + ) + .count() + ) + + return service + + +# Attendance Records + + +@router.post( + "/services/{service_id}/attendance", response_model=AttendanceRecordResponse +) +def record_attendance( + service_id: int, + attendance: AttendanceRecordCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Record attendance for a service (admin only)""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + service = db.query(Service).filter(Service.id == service_id).first() + if not service: + raise HTTPException(status_code=404, detail="Service not found") + + # Check if attendance already recorded + existing_record = ( + db.query(AttendanceRecord) + .filter( + AttendanceRecord.service_id == service_id, + AttendanceRecord.user_id == attendance.user_id, + ) + .first() + ) + + if existing_record: + raise HTTPException( + status_code=400, detail="Attendance already recorded for this user" + ) + + db_record = AttendanceRecord( + **attendance.dict(), service_id=service_id, recorded_by=current_user.id + ) + + db.add(db_record) + db.commit() + db.refresh(db_record) + + db_record.user_name = f"{db_record.user.first_name} {db_record.user.last_name}" + db_record.recorder_name = f"{current_user.first_name} {current_user.last_name}" + + return db_record + + +@router.get( + "/services/{service_id}/attendance", response_model=List[AttendanceRecordResponse] +) +def get_service_attendance( + service_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get attendance records for a service""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + records = ( + db.query(AttendanceRecord) + .filter(AttendanceRecord.service_id == service_id) + .all() + ) + + for record in records: + record.user_name = f"{record.user.first_name} {record.user.last_name}" + record.recorder_name = ( + f"{record.recorder.first_name} {record.recorder.last_name}" + ) + + return records + + +@router.put("/attendance/{record_id}", response_model=AttendanceRecordResponse) +def update_attendance_record( + record_id: int, + attendance_update: AttendanceRecordUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update attendance record (admin only)""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + record = db.query(AttendanceRecord).filter(AttendanceRecord.id == record_id).first() + + if not record: + raise HTTPException(status_code=404, detail="Attendance record not found") + + for field, value in attendance_update.dict(exclude_unset=True).items(): + setattr(record, field, value) + + db.commit() + db.refresh(record) + + return record + + +# Personal Attendance + + +@router.get("/my-attendance", response_model=List[AttendanceRecordResponse]) +def get_my_attendance( + skip: int = 0, + limit: int = 100, + service_type: Optional[ServiceType] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get current user's attendance records""" + query = db.query(AttendanceRecord).filter( + AttendanceRecord.user_id == current_user.id + ) + + if service_type: + query = query.join(Service).filter(Service.service_type == service_type) + + records = ( + query.order_by(AttendanceRecord.recorded_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + for record in records: + record.user_name = f"{current_user.first_name} {current_user.last_name}" + record.recorder_name = ( + f"{record.recorder.first_name} {record.recorder.last_name}" + ) + + return records + + +@router.get("/my-stats", response_model=AttendanceStats) +def get_my_attendance_stats( + start_date: Optional[date] = None, + end_date: Optional[date] = None, + service_type: Optional[ServiceType] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get personal attendance statistics""" + + # Default to current year if no dates provided + if not start_date: + start_date = date(datetime.now().year, 1, 1) + if not end_date: + end_date = date.today() + + # Base queries + services_query = db.query(Service).filter( + Service.service_date >= start_date, + Service.service_date <= end_date, + Service.is_active, + ) + + attendance_query = ( + db.query(AttendanceRecord) + .filter(AttendanceRecord.user_id == current_user.id) + .join(Service) + .filter(Service.service_date >= start_date, Service.service_date <= end_date) + ) + + if service_type: + services_query = services_query.filter(Service.service_type == service_type) + attendance_query = attendance_query.filter(Service.service_type == service_type) + + total_services = services_query.count() + + attendance_records = attendance_query.all() + services_attended = len( + [ + r + for r in attendance_records + if r.status in [AttendanceStatus.PRESENT, AttendanceStatus.LATE] + ] + ) + + attendance_percentage = ( + (services_attended / total_services * 100) if total_services > 0 else 0 + ) + + # Find most attended service type + if not service_type: + service_type_counts = ( + db.query( + Service.service_type, func.count(AttendanceRecord.id).label("count") + ) + .join(AttendanceRecord) + .filter( + AttendanceRecord.user_id == current_user.id, + AttendanceRecord.status.in_( + [AttendanceStatus.PRESENT, AttendanceStatus.LATE] + ), + Service.service_date >= start_date, + Service.service_date <= end_date, + ) + .group_by(Service.service_type) + .order_by(func.count(AttendanceRecord.id).desc()) + .first() + ) + + most_attended_service_type = ( + service_type_counts.service_type if service_type_counts else None + ) + else: + most_attended_service_type = service_type + + # Calculate streaks (simplified) + recent_services = ( + db.query(Service) + .filter(Service.service_date <= date.today()) + .order_by(Service.service_date.desc()) + .limit(10) + .all() + ) + + current_streak = 0 + for service in recent_services: + record = ( + db.query(AttendanceRecord) + .filter( + AttendanceRecord.service_id == service.id, + AttendanceRecord.user_id == current_user.id, + AttendanceRecord.status.in_( + [AttendanceStatus.PRESENT, AttendanceStatus.LATE] + ), + ) + .first() + ) + + if record: + current_streak += 1 + else: + break + + return AttendanceStats( + total_services=total_services, + services_attended=services_attended, + attendance_percentage=attendance_percentage, + most_attended_service_type=most_attended_service_type, + current_streak=current_streak, + longest_streak=current_streak, # Simplified - would need more complex logic for actual longest + ) + + +# Attendance Goals + + +@router.post("/goals", response_model=AttendanceGoalResponse) +def create_attendance_goal( + goal: AttendanceGoalCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create attendance goal""" + + # Deactivate existing goals for the same service type + db.query(AttendanceGoal).filter( + AttendanceGoal.user_id == current_user.id, + AttendanceGoal.service_type == goal.service_type, + AttendanceGoal.is_active, + ).update({"is_active": False}) + + db_goal = AttendanceGoal(**goal.dict(), user_id=current_user.id) + db.add(db_goal) + db.commit() + db.refresh(db_goal) + + # Calculate current percentage + services_count = ( + db.query(Service) + .filter( + Service.service_type == goal.service_type, + Service.service_date >= goal.start_date, + Service.service_date <= min(goal.end_date, date.today()), + ) + .count() + ) + + attended_count = ( + db.query(AttendanceRecord) + .join(Service) + .filter( + Service.service_type == goal.service_type, + Service.service_date >= goal.start_date, + Service.service_date <= min(goal.end_date, date.today()), + AttendanceRecord.user_id == current_user.id, + AttendanceRecord.status.in_( + [AttendanceStatus.PRESENT, AttendanceStatus.LATE] + ), + ) + .count() + ) + + db_goal.current_percentage = ( + (attended_count / services_count * 100) if services_count > 0 else 0 + ) + + return db_goal + + +@router.get("/goals", response_model=List[AttendanceGoalResponse]) +def get_my_attendance_goals( + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + """Get user's attendance goals""" + + goals = ( + db.query(AttendanceGoal) + .filter(AttendanceGoal.user_id == current_user.id, AttendanceGoal.is_active) + .all() + ) + + for goal in goals: + # Calculate current percentage + services_count = ( + db.query(Service) + .filter( + Service.service_type == goal.service_type, + Service.service_date >= goal.start_date, + Service.service_date <= min(goal.end_date, date.today()), + ) + .count() + ) + + attended_count = ( + db.query(AttendanceRecord) + .join(Service) + .filter( + Service.service_type == goal.service_type, + Service.service_date >= goal.start_date, + Service.service_date <= min(goal.end_date, date.today()), + AttendanceRecord.user_id == current_user.id, + AttendanceRecord.status.in_( + [AttendanceStatus.PRESENT, AttendanceStatus.LATE] + ), + ) + .count() + ) + + goal.current_percentage = ( + (attended_count / services_count * 100) if services_count > 0 else 0 + ) + + return goals diff --git a/app/api/donations.py b/app/api/donations.py new file mode 100644 index 0000000..3600191 --- /dev/null +++ b/app/api/donations.py @@ -0,0 +1,311 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import func, extract +from typing import List, Optional +from datetime import datetime, timedelta +from decimal import Decimal +from app.db.base import get_db +from app.models.user import User +from app.models.donation import Donation, DonationGoal, TithePledge, DonationType +from app.schemas.donation import ( + DonationCreate, + DonationResponse, + DonationGoalCreate, + DonationGoalResponse, + TithePledgeCreate, + TithePledgeResponse, + DonationStats, + MonthlyDonationSummary, +) +from app.api.auth import get_current_user + +router = APIRouter() + + +@router.post("/", response_model=DonationResponse) +def create_donation( + donation: DonationCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create a new donation""" + donation_data = donation.dict() + if not donation_data.get("donation_date"): + donation_data["donation_date"] = datetime.utcnow() + + # Calculate next due date for recurring donations + if donation.is_recurring and donation.recurring_frequency: + if donation.recurring_frequency == "weekly": + donation_data["next_due_date"] = donation_data["donation_date"] + timedelta( + weeks=1 + ) + elif donation.recurring_frequency == "monthly": + donation_data["next_due_date"] = donation_data["donation_date"] + timedelta( + days=30 + ) + elif donation.recurring_frequency == "yearly": + donation_data["next_due_date"] = donation_data["donation_date"] + timedelta( + days=365 + ) + + db_donation = Donation(**donation_data, donor_id=current_user.id) + db.add(db_donation) + db.commit() + db.refresh(db_donation) + + db_donation.donor_name = f"{current_user.first_name} {current_user.last_name}" + return db_donation + + +@router.get("/", response_model=List[DonationResponse]) +def get_my_donations( + skip: int = 0, + limit: int = 100, + donation_type: Optional[DonationType] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get current user's donations""" + query = db.query(Donation).filter(Donation.donor_id == current_user.id) + + if donation_type: + query = query.filter(Donation.donation_type == donation_type) + + donations = ( + query.order_by(Donation.donation_date.desc()).offset(skip).limit(limit).all() + ) + + for donation in donations: + donation.donor_name = f"{current_user.first_name} {current_user.last_name}" + + return donations + + +@router.get("/all", response_model=List[DonationResponse]) +def get_all_donations( + skip: int = 0, + limit: int = 100, + donation_type: Optional[DonationType] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get all donations (admin only)""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + query = db.query(Donation) + + if donation_type: + query = query.filter(Donation.donation_type == donation_type) + + donations = ( + query.order_by(Donation.donation_date.desc()).offset(skip).limit(limit).all() + ) + + for donation in donations: + if not donation.is_anonymous: + donation.donor_name = ( + f"{donation.donor.first_name} {donation.donor.last_name}" + ) + else: + donation.donor_name = "Anonymous" + + return donations + + +@router.get("/stats", response_model=DonationStats) +def get_donation_stats( + year: Optional[int] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get donation statistics""" + query = db.query(Donation) + + if not current_user.is_admin: + query = query.filter(Donation.donor_id == current_user.id) + + if year: + query = query.filter(extract("year", Donation.donation_date) == year) + + donations = query.all() + + total_donations = sum(d.amount for d in donations) + total_tithes = sum( + d.amount for d in donations if d.donation_type == DonationType.TITHE + ) + total_offerings = sum( + d.amount for d in donations if d.donation_type == DonationType.OFFERING + ) + donation_count = len(donations) + average_donation = ( + total_donations / donation_count if donation_count > 0 else Decimal("0") + ) + + return DonationStats( + total_donations=total_donations, + total_tithes=total_tithes, + total_offerings=total_offerings, + donation_count=donation_count, + average_donation=average_donation, + ) + + +@router.get("/monthly-summary", response_model=List[MonthlyDonationSummary]) +def get_monthly_donation_summary( + year: int = datetime.now().year, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get monthly donation summary""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + results = ( + db.query( + extract("month", Donation.donation_date).label("month"), + extract("year", Donation.donation_date).label("year"), + func.sum(Donation.amount).label("total_amount"), + func.count(Donation.id).label("donation_count"), + func.sum( + func.case( + (Donation.donation_type == DonationType.TITHE, Donation.amount), + else_=0, + ) + ).label("tithe_amount"), + func.sum( + func.case( + (Donation.donation_type == DonationType.OFFERING, Donation.amount), + else_=0, + ) + ).label("offering_amount"), + ) + .filter(extract("year", Donation.donation_date) == year) + .group_by( + extract("month", Donation.donation_date), + extract("year", Donation.donation_date), + ) + .all() + ) + + months = [ + "", + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ] + + summary = [] + for result in results: + summary.append( + MonthlyDonationSummary( + month=months[int(result.month)], + year=int(result.year), + total_amount=result.total_amount or Decimal("0"), + donation_count=result.donation_count, + tithe_amount=result.tithe_amount or Decimal("0"), + offering_amount=result.offering_amount or Decimal("0"), + ) + ) + + return summary + + +# Donation Goals + + +@router.post("/goals", response_model=DonationGoalResponse) +def create_donation_goal( + goal: DonationGoalCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create a donation goal (admin only)""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + db_goal = DonationGoal(**goal.dict(), created_by=current_user.id) + db.add(db_goal) + db.commit() + db.refresh(db_goal) + + db_goal.creator_name = f"{current_user.first_name} {current_user.last_name}" + db_goal.progress_percentage = 0.0 + db_goal.days_remaining = (db_goal.end_date - datetime.utcnow()).days + + return db_goal + + +@router.get("/goals", response_model=List[DonationGoalResponse]) +def get_donation_goals( + active_only: bool = True, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get donation goals""" + query = db.query(DonationGoal) + + if active_only: + query = query.filter(DonationGoal.is_active) + + goals = query.order_by(DonationGoal.created_at.desc()).all() + + for goal in goals: + goal.creator_name = f"{goal.creator.first_name} {goal.creator.last_name}" + goal.progress_percentage = ( + (float(goal.current_amount) / float(goal.target_amount)) * 100 + if goal.target_amount > 0 + else 0 + ) + goal.days_remaining = max(0, (goal.end_date - datetime.utcnow()).days) + + return goals + + +# Tithe Pledges + + +@router.post("/tithe-pledge", response_model=TithePledgeResponse) +def create_tithe_pledge( + pledge: TithePledgeCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create or update tithe pledge""" + # Deactivate existing pledges + db.query(TithePledge).filter( + TithePledge.user_id == current_user.id, TithePledge.is_active + ).update({"is_active": False}) + + db_pledge = TithePledge(**pledge.dict(), user_id=current_user.id) + db.add(db_pledge) + db.commit() + db.refresh(db_pledge) + + return db_pledge + + +@router.get("/tithe-pledge", response_model=TithePledgeResponse) +def get_my_tithe_pledge( + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + """Get current user's active tithe pledge""" + pledge = ( + db.query(TithePledge) + .filter(TithePledge.user_id == current_user.id, TithePledge.is_active) + .first() + ) + + if not pledge: + raise HTTPException(status_code=404, detail="No active tithe pledge found") + + return pledge diff --git a/app/api/messages.py b/app/api/messages.py new file mode 100644 index 0000000..229d495 --- /dev/null +++ b/app/api/messages.py @@ -0,0 +1,413 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import or_, and_, func +from typing import List +from app.db.base import get_db +from app.models.user import User, Connection +from app.models.message import ( + Conversation, + Message, + GroupChat, + GroupChatMember, + GroupMessage, +) +from app.schemas.message import ( + MessageCreate, + MessageResponse, + ConversationResponse, + GroupChatCreate, + GroupChatResponse, + GroupMessageCreate, + GroupMessageResponse, +) +from app.api.auth import get_current_user + +router = APIRouter() + +# Direct Messages + + +@router.get("/conversations", response_model=List[ConversationResponse]) +def get_conversations( + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + """Get user's conversations""" + conversations = ( + db.query(Conversation) + .filter( + or_( + Conversation.participant_1_id == current_user.id, + Conversation.participant_2_id == current_user.id, + ) + ) + .order_by(Conversation.updated_at.desc()) + .all() + ) + + result = [] + for conv in conversations: + # Determine other participant + other_participant = ( + conv.participant_2 + if conv.participant_1_id == current_user.id + else conv.participant_1 + ) + + # Get last message + last_message = ( + db.query(Message) + .filter(Message.conversation_id == conv.id) + .order_by(Message.created_at.desc()) + .first() + ) + + # Get unread count + unread_count = ( + db.query(Message) + .filter( + Message.conversation_id == conv.id, + Message.receiver_id == current_user.id, + Message.is_read is False, + ) + .count() + ) + + conv_response = ConversationResponse( + id=conv.id, + participant_1_id=conv.participant_1_id, + participant_2_id=conv.participant_2_id, + created_at=conv.created_at, + updated_at=conv.updated_at, + other_participant_name=f"{other_participant.first_name} {other_participant.last_name}", + other_participant_id=other_participant.id, + last_message=last_message.content if last_message else None, + unread_count=unread_count, + ) + result.append(conv_response) + + return result + + +@router.get("/conversations/{user_id}/messages", response_model=List[MessageResponse]) +def get_conversation_messages( + user_id: int, + skip: int = 0, + limit: int = 50, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get messages in a conversation with specific user""" + + # Check if users are connected + connection = ( + db.query(Connection) + .filter( + or_( + and_( + Connection.sender_id == current_user.id, + Connection.receiver_id == user_id, + ), + and_( + Connection.sender_id == user_id, + Connection.receiver_id == current_user.id, + ), + ), + Connection.status == "accepted", + ) + .first() + ) + + if not connection: + raise HTTPException(status_code=403, detail="Can only message connections") + + # Get or create conversation + conversation = ( + db.query(Conversation) + .filter( + or_( + and_( + Conversation.participant_1_id == current_user.id, + Conversation.participant_2_id == user_id, + ), + and_( + Conversation.participant_1_id == user_id, + Conversation.participant_2_id == current_user.id, + ), + ) + ) + .first() + ) + + if not conversation: + conversation = Conversation( + participant_1_id=min(current_user.id, user_id), + participant_2_id=max(current_user.id, user_id), + ) + db.add(conversation) + db.commit() + db.refresh(conversation) + + # Get messages + messages = ( + db.query(Message) + .filter(Message.conversation_id == conversation.id) + .order_by(Message.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + # Mark messages as read + db.query(Message).filter( + Message.conversation_id == conversation.id, + Message.receiver_id == current_user.id, + Message.is_read is False, + ).update({"is_read": True, "read_at": func.now()}) + db.commit() + + # Add sender names + for message in messages: + message.sender_name = f"{message.sender.first_name} {message.sender.last_name}" + + return messages + + +@router.post("/send", response_model=MessageResponse) +def send_message( + message: MessageCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Send a direct message""" + + # Check if users are connected + connection = ( + db.query(Connection) + .filter( + or_( + and_( + Connection.sender_id == current_user.id, + Connection.receiver_id == message.receiver_id, + ), + and_( + Connection.sender_id == message.receiver_id, + Connection.receiver_id == current_user.id, + ), + ), + Connection.status == "accepted", + ) + .first() + ) + + if not connection: + raise HTTPException(status_code=403, detail="Can only message connections") + + # Get or create conversation + conversation = ( + db.query(Conversation) + .filter( + or_( + and_( + Conversation.participant_1_id == current_user.id, + Conversation.participant_2_id == message.receiver_id, + ), + and_( + Conversation.participant_1_id == message.receiver_id, + Conversation.participant_2_id == current_user.id, + ), + ) + ) + .first() + ) + + if not conversation: + conversation = Conversation( + participant_1_id=min(current_user.id, message.receiver_id), + participant_2_id=max(current_user.id, message.receiver_id), + ) + db.add(conversation) + db.commit() + db.refresh(conversation) + + # Create message + db_message = Message( + conversation_id=conversation.id, + sender_id=current_user.id, + receiver_id=message.receiver_id, + content=message.content, + message_type=message.message_type, + file_url=message.file_url, + ) + + db.add(db_message) + + # Update conversation timestamp + conversation.updated_at = func.now() + + db.commit() + db.refresh(db_message) + + db_message.sender_name = f"{current_user.first_name} {current_user.last_name}" + return db_message + + +# Group Chats + + +@router.post("/groups", response_model=GroupChatResponse) +def create_group_chat( + group_data: GroupChatCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create a group chat""" + + # Create group chat + group_chat = GroupChat( + name=group_data.name, + description=group_data.description, + created_by=current_user.id, + ) + + db.add(group_chat) + db.commit() + db.refresh(group_chat) + + # Add creator as admin member + creator_member = GroupChatMember( + group_chat_id=group_chat.id, user_id=current_user.id, is_admin=True + ) + db.add(creator_member) + + # Add other members + for member_id in group_data.member_ids: + if member_id != current_user.id: # Don't add creator twice + member = GroupChatMember(group_chat_id=group_chat.id, user_id=member_id) + db.add(member) + + db.commit() + + group_chat.creator_name = f"{current_user.first_name} {current_user.last_name}" + group_chat.member_count = len(group_data.member_ids) + 1 # Include creator + + return group_chat + + +@router.get("/groups", response_model=List[GroupChatResponse]) +def get_my_group_chats( + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + """Get user's group chats""" + + # Get groups where user is a member + memberships = ( + db.query(GroupChatMember) + .filter(GroupChatMember.user_id == current_user.id) + .all() + ) + + groups = [] + for membership in memberships: + group = membership.group_chat + if group.is_active: + # Get member count + member_count = ( + db.query(GroupChatMember) + .filter(GroupChatMember.group_chat_id == group.id) + .count() + ) + + # Get last message + last_message = ( + db.query(GroupMessage) + .filter(GroupMessage.group_chat_id == group.id) + .order_by(GroupMessage.created_at.desc()) + .first() + ) + + group.creator_name = f"{group.creator.first_name} {group.creator.last_name}" + group.member_count = member_count + group.last_message = last_message.content if last_message else None + + groups.append(group) + + return groups + + +@router.get("/groups/{group_id}/messages", response_model=List[GroupMessageResponse]) +def get_group_messages( + group_id: int, + skip: int = 0, + limit: int = 50, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get group chat messages""" + + # Check if user is a member + membership = ( + db.query(GroupChatMember) + .filter( + GroupChatMember.group_chat_id == group_id, + GroupChatMember.user_id == current_user.id, + ) + .first() + ) + + if not membership: + raise HTTPException(status_code=403, detail="Not a member of this group") + + # Get messages + messages = ( + db.query(GroupMessage) + .filter(GroupMessage.group_chat_id == group_id) + .order_by(GroupMessage.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + # Add sender names + for message in messages: + message.sender_name = f"{message.sender.first_name} {message.sender.last_name}" + + return messages + + +@router.post("/groups/{group_id}/send", response_model=GroupMessageResponse) +def send_group_message( + group_id: int, + message: GroupMessageCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Send a group message""" + + # Check if user is a member + membership = ( + db.query(GroupChatMember) + .filter( + GroupChatMember.group_chat_id == group_id, + GroupChatMember.user_id == current_user.id, + ) + .first() + ) + + if not membership: + raise HTTPException(status_code=403, detail="Not a member of this group") + + # Create message + db_message = GroupMessage( + group_chat_id=group_id, + sender_id=current_user.id, + content=message.content, + message_type=message.message_type, + file_url=message.file_url, + ) + + db.add(db_message) + db.commit() + db.refresh(db_message) + + db_message.sender_name = f"{current_user.first_name} {current_user.last_name}" + return db_message diff --git a/app/api/ministries.py b/app/api/ministries.py new file mode 100644 index 0000000..6d5b0aa --- /dev/null +++ b/app/api/ministries.py @@ -0,0 +1,325 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +from app.db.base import get_db +from app.models.user import User +from app.models.ministry import ( + Ministry, + MinistryMember, + MinistryActivity, + MinistryRequest, + MinistryRole, +) +from app.schemas.ministry import ( + MinistryCreate, + MinistryUpdate, + MinistryResponse, + MinistryMemberResponse, + MinistryActivityCreate, + MinistryActivityResponse, + MinistryJoinRequest, + MinistryRequestResponse, +) +from app.api.auth import get_current_user + +router = APIRouter() + + +@router.post("/", response_model=MinistryResponse) +def create_ministry( + ministry: MinistryCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create a new ministry""" + db_ministry = Ministry(**ministry.dict(), leader_id=current_user.id) + db.add(db_ministry) + db.commit() + db.refresh(db_ministry) + + # Add creator as leader + member = MinistryMember( + ministry_id=db_ministry.id, user_id=current_user.id, role=MinistryRole.LEADER + ) + db.add(member) + db.commit() + + db_ministry.leader_name = f"{current_user.first_name} {current_user.last_name}" + db_ministry.member_count = 1 + db_ministry.is_member = True + db_ministry.user_role = MinistryRole.LEADER + + return db_ministry + + +@router.get("/", response_model=List[MinistryResponse]) +def get_ministries( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get all ministries""" + ministries = ( + db.query(Ministry).filter(Ministry.is_active).offset(skip).limit(limit).all() + ) + + for ministry in ministries: + ministry.leader_name = ( + f"{ministry.leader.first_name} {ministry.leader.last_name}" + ) + ministry.member_count = ( + db.query(MinistryMember) + .filter(MinistryMember.ministry_id == ministry.id, MinistryMember.is_active) + .count() + ) + + # Check if current user is a member and their role + member = ( + db.query(MinistryMember) + .filter( + MinistryMember.ministry_id == ministry.id, + MinistryMember.user_id == current_user.id, + MinistryMember.is_active, + ) + .first() + ) + + ministry.is_member = member is not None + ministry.user_role = member.role if member else None + + return ministries + + +@router.get("/my-ministries", response_model=List[MinistryResponse]) +def get_my_ministries( + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + """Get ministries where current user is a member""" + memberships = ( + db.query(MinistryMember) + .filter(MinistryMember.user_id == current_user.id, MinistryMember.is_active) + .all() + ) + + ministries = [] + for membership in memberships: + ministry = membership.ministry + ministry.leader_name = ( + f"{ministry.leader.first_name} {ministry.leader.last_name}" + ) + ministry.member_count = ( + db.query(MinistryMember) + .filter(MinistryMember.ministry_id == ministry.id, MinistryMember.is_active) + .count() + ) + ministry.is_member = True + ministry.user_role = membership.role + ministries.append(ministry) + + return ministries + + +@router.get("/{ministry_id}", response_model=MinistryResponse) +def get_ministry( + ministry_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get specific ministry""" + ministry = db.query(Ministry).filter(Ministry.id == ministry_id).first() + if not ministry: + raise HTTPException(status_code=404, detail="Ministry not found") + + ministry.leader_name = f"{ministry.leader.first_name} {ministry.leader.last_name}" + ministry.member_count = ( + db.query(MinistryMember) + .filter(MinistryMember.ministry_id == ministry.id, MinistryMember.is_active) + .count() + ) + + member = ( + db.query(MinistryMember) + .filter( + MinistryMember.ministry_id == ministry.id, + MinistryMember.user_id == current_user.id, + MinistryMember.is_active, + ) + .first() + ) + + ministry.is_member = member is not None + ministry.user_role = member.role if member else None + + return ministry + + +@router.put("/{ministry_id}", response_model=MinistryResponse) +def update_ministry( + ministry_id: int, + ministry_update: MinistryUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update ministry (leaders only)""" + ministry = db.query(Ministry).filter(Ministry.id == ministry_id).first() + if not ministry: + raise HTTPException(status_code=404, detail="Ministry not found") + + # Check if user is leader or co-leader + member = ( + db.query(MinistryMember) + .filter( + MinistryMember.ministry_id == ministry_id, + MinistryMember.user_id == current_user.id, + MinistryMember.is_active, + ) + .first() + ) + + if not member or member.role not in [MinistryRole.LEADER, MinistryRole.CO_LEADER]: + raise HTTPException(status_code=403, detail="Only leaders can update ministry") + + for field, value in ministry_update.dict(exclude_unset=True).items(): + setattr(ministry, field, value) + + db.commit() + db.refresh(ministry) + return ministry + + +@router.post("/{ministry_id}/join", response_model=MinistryRequestResponse) +def request_to_join_ministry( + ministry_id: int, + join_request: MinistryJoinRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Request to join a ministry""" + ministry = db.query(Ministry).filter(Ministry.id == ministry_id).first() + if not ministry: + raise HTTPException(status_code=404, detail="Ministry not found") + + # Check if already a member + existing_member = ( + db.query(MinistryMember) + .filter( + MinistryMember.ministry_id == ministry_id, + MinistryMember.user_id == current_user.id, + MinistryMember.is_active, + ) + .first() + ) + + if existing_member: + raise HTTPException(status_code=400, detail="Already a member of this ministry") + + # Check if already has pending request + existing_request = ( + db.query(MinistryRequest) + .filter( + MinistryRequest.ministry_id == ministry_id, + MinistryRequest.user_id == current_user.id, + MinistryRequest.status == "pending", + ) + .first() + ) + + if existing_request: + raise HTTPException(status_code=400, detail="Already have a pending request") + + request = MinistryRequest( + ministry_id=ministry_id, user_id=current_user.id, message=join_request.message + ) + + db.add(request) + db.commit() + db.refresh(request) + + request.user_name = f"{current_user.first_name} {current_user.last_name}" + request.ministry_name = ministry.name + + return request + + +@router.get("/{ministry_id}/members", response_model=List[MinistryMemberResponse]) +def get_ministry_members( + ministry_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get ministry members""" + ministry = db.query(Ministry).filter(Ministry.id == ministry_id).first() + if not ministry: + raise HTTPException(status_code=404, detail="Ministry not found") + + members = ( + db.query(MinistryMember) + .filter(MinistryMember.ministry_id == ministry_id, MinistryMember.is_active) + .all() + ) + + for member in members: + member.user_name = f"{member.user.first_name} {member.user.last_name}" + member.ministry_name = ministry.name + + return members + + +@router.get("/{ministry_id}/activities", response_model=List[MinistryActivityResponse]) +def get_ministry_activities( + ministry_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get ministry activities""" + activities = ( + db.query(MinistryActivity) + .filter(MinistryActivity.ministry_id == ministry_id) + .order_by(MinistryActivity.activity_date.desc()) + .all() + ) + + for activity in activities: + activity.creator_name = ( + f"{activity.creator.first_name} {activity.creator.last_name}" + ) + + return activities + + +@router.post("/{ministry_id}/activities", response_model=MinistryActivityResponse) +def create_ministry_activity( + ministry_id: int, + activity: MinistryActivityCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create ministry activity (members only)""" + # Check if user is a member + member = ( + db.query(MinistryMember) + .filter( + MinistryMember.ministry_id == ministry_id, + MinistryMember.user_id == current_user.id, + MinistryMember.is_active, + ) + .first() + ) + + if not member: + raise HTTPException( + status_code=403, detail="Only ministry members can create activities" + ) + + db_activity = MinistryActivity( + **activity.dict(), ministry_id=ministry_id, created_by=current_user.id + ) + + db.add(db_activity) + db.commit() + db.refresh(db_activity) + + db_activity.creator_name = f"{current_user.first_name} {current_user.last_name}" + + return db_activity diff --git a/app/api/notifications.py b/app/api/notifications.py new file mode 100644 index 0000000..f0e4fe2 --- /dev/null +++ b/app/api/notifications.py @@ -0,0 +1,147 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +from app.db.base import get_db +from app.models.user import User +from app.models.notification import Notification, NotificationSettings +from app.schemas.notification import ( + NotificationResponse, + NotificationSettingsResponse, + NotificationSettingsUpdate, +) +from app.api.auth import get_current_user +from app.services.notification_service import NotificationService + +router = APIRouter() + + +@router.get("/", response_model=List[NotificationResponse]) +def get_notifications( + skip: int = 0, + limit: int = 50, + unread_only: bool = False, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get user's notifications""" + query = db.query(Notification).filter(Notification.recipient_id == current_user.id) + + if unread_only: + query = query.filter(Notification.is_read is False) + + notifications = ( + query.order_by(Notification.created_at.desc()).offset(skip).limit(limit).all() + ) + + # Add sender names + for notification in notifications: + if notification.sender: + notification.sender_name = ( + f"{notification.sender.first_name} {notification.sender.last_name}" + ) + + return notifications + + +@router.get("/unread-count") +def get_unread_count( + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + """Get count of unread notifications""" + count = NotificationService.get_unread_count(db, current_user.id) + return {"unread_count": count} + + +@router.put("/{notification_id}/read") +def mark_notification_read( + notification_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Mark a notification as read""" + success = NotificationService.mark_as_read(db, notification_id, current_user.id) + if not success: + raise HTTPException(status_code=404, detail="Notification not found") + return {"message": "Notification marked as read"} + + +@router.put("/mark-all-read") +def mark_all_notifications_read( + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + """Mark all notifications as read""" + count = NotificationService.mark_all_as_read(db, current_user.id) + return {"message": f"Marked {count} notifications as read"} + + +@router.delete("/{notification_id}") +def delete_notification( + notification_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete a notification""" + notification = ( + db.query(Notification) + .filter( + Notification.id == notification_id, + Notification.recipient_id == current_user.id, + ) + .first() + ) + + if not notification: + raise HTTPException(status_code=404, detail="Notification not found") + + db.delete(notification) + db.commit() + return {"message": "Notification deleted"} + + +@router.get("/settings", response_model=NotificationSettingsResponse) +def get_notification_settings( + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + """Get user's notification settings""" + settings = ( + db.query(NotificationSettings) + .filter(NotificationSettings.user_id == current_user.id) + .first() + ) + + if not settings: + # Create default settings + settings = NotificationSettings(user_id=current_user.id) + db.add(settings) + db.commit() + db.refresh(settings) + + return settings + + +@router.put("/settings", response_model=NotificationSettingsResponse) +def update_notification_settings( + settings_update: NotificationSettingsUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update user's notification settings""" + settings = ( + db.query(NotificationSettings) + .filter(NotificationSettings.user_id == current_user.id) + .first() + ) + + if not settings: + settings = NotificationSettings(user_id=current_user.id) + db.add(settings) + db.commit() + db.refresh(settings) + + # Update settings + for field, value in settings_update.dict(exclude_unset=True).items(): + setattr(settings, field, value) + + db.commit() + db.refresh(settings) + return settings diff --git a/app/api/prayers.py b/app/api/prayers.py new file mode 100644 index 0000000..feab85c --- /dev/null +++ b/app/api/prayers.py @@ -0,0 +1,427 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import or_, and_ +from typing import List, Optional +from datetime import datetime +from app.db.base import get_db +from app.models.user import User, Connection +from app.models.ministry import MinistryMember +from app.models.prayer import ( + PrayerRequest, + Prayer, + PrayerPrivacyLevel, + PrayerStatus, + PrayerCategory, +) +from app.schemas.prayer import ( + PrayerRequestCreate, + PrayerRequestUpdate, + PrayerRequestResponse, + PrayerCreate, + PrayerResponse, + PrayerStats, +) +from app.api.auth import get_current_user + +router = APIRouter() + + +def can_view_prayer_request( + prayer_request: PrayerRequest, current_user: User, db: Session +) -> bool: + """Check if user can view a prayer request based on privacy level""" + + if prayer_request.requester_id == current_user.id: + return True + + if prayer_request.privacy_level == PrayerPrivacyLevel.PUBLIC: + return True + + if prayer_request.privacy_level == PrayerPrivacyLevel.PRIVATE: + return False + + if prayer_request.privacy_level == PrayerPrivacyLevel.CONNECTIONS_ONLY: + # Check if users are connected + connection = ( + db.query(Connection) + .filter( + or_( + and_( + Connection.sender_id == current_user.id, + Connection.receiver_id == prayer_request.requester_id, + ), + and_( + Connection.sender_id == prayer_request.requester_id, + Connection.receiver_id == current_user.id, + ), + ), + Connection.status == "accepted", + ) + .first() + ) + return connection is not None + + if ( + prayer_request.privacy_level == PrayerPrivacyLevel.MINISTRY_ONLY + and prayer_request.ministry_id + ): + # Check if user is in the same ministry + membership = ( + db.query(MinistryMember) + .filter( + MinistryMember.ministry_id == prayer_request.ministry_id, + MinistryMember.user_id == current_user.id, + MinistryMember.is_active, + ) + .first() + ) + return membership is not None + + return False + + +@router.post("/", response_model=PrayerRequestResponse) +def create_prayer_request( + prayer_request: PrayerRequestCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create a new prayer request""" + + # Validate ministry access if ministry_only privacy + if ( + prayer_request.privacy_level == PrayerPrivacyLevel.MINISTRY_ONLY + and prayer_request.ministry_id + ): + membership = ( + db.query(MinistryMember) + .filter( + MinistryMember.ministry_id == prayer_request.ministry_id, + MinistryMember.user_id == current_user.id, + MinistryMember.is_active, + ) + .first() + ) + + if not membership: + raise HTTPException( + status_code=403, + detail="Must be a ministry member to create ministry-only requests", + ) + + db_request = PrayerRequest(**prayer_request.dict(), requester_id=current_user.id) + db.add(db_request) + db.commit() + db.refresh(db_request) + + # Add response metadata + db_request.requester_name = ( + f"{current_user.first_name} {current_user.last_name}" + if not db_request.is_anonymous + else "Anonymous" + ) + db_request.prayer_count = 0 + db_request.has_prayed = False + + if db_request.ministry_id: + db_request.ministry_name = db_request.ministry.name + + return db_request + + +@router.get("/", response_model=List[PrayerRequestResponse]) +def get_prayer_requests( + skip: int = 0, + limit: int = 100, + category: Optional[PrayerCategory] = None, + status: Optional[PrayerStatus] = None, + privacy_level: Optional[PrayerPrivacyLevel] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get prayer requests based on user's access level""" + + query = db.query(PrayerRequest) + + # Apply filters + if category: + query = query.filter(PrayerRequest.category == category) + if status: + query = query.filter(PrayerRequest.status == status) + if privacy_level: + query = query.filter(PrayerRequest.privacy_level == privacy_level) + + prayer_requests = ( + query.order_by(PrayerRequest.created_at.desc()).offset(skip).limit(limit).all() + ) + + # Filter based on privacy and add metadata + accessible_requests = [] + for request in prayer_requests: + if can_view_prayer_request(request, current_user, db): + # Add metadata + request.requester_name = ( + f"{request.requester.first_name} {request.requester.last_name}" + if not request.is_anonymous + else "Anonymous" + ) + + request.prayer_count = ( + db.query(Prayer).filter(Prayer.prayer_request_id == request.id).count() + ) + + request.has_prayed = ( + db.query(Prayer) + .filter( + Prayer.prayer_request_id == request.id, + Prayer.prayer_warrior_id == current_user.id, + ) + .first() + is not None + ) + + if request.ministry_id: + request.ministry_name = request.ministry.name + + accessible_requests.append(request) + + return accessible_requests + + +@router.get("/my-requests", response_model=List[PrayerRequestResponse]) +def get_my_prayer_requests( + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + """Get current user's prayer requests""" + + requests = ( + db.query(PrayerRequest) + .filter(PrayerRequest.requester_id == current_user.id) + .order_by(PrayerRequest.created_at.desc()) + .all() + ) + + for request in requests: + request.requester_name = f"{current_user.first_name} {current_user.last_name}" + request.prayer_count = ( + db.query(Prayer).filter(Prayer.prayer_request_id == request.id).count() + ) + request.has_prayed = False # Can't pray for own request + + if request.ministry_id: + request.ministry_name = request.ministry.name + + return requests + + +@router.get("/{request_id}", response_model=PrayerRequestResponse) +def get_prayer_request( + request_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get specific prayer request""" + + prayer_request = ( + db.query(PrayerRequest).filter(PrayerRequest.id == request_id).first() + ) + + if not prayer_request: + raise HTTPException(status_code=404, detail="Prayer request not found") + + if not can_view_prayer_request(prayer_request, current_user, db): + raise HTTPException( + status_code=403, detail="Access denied to this prayer request" + ) + + # Add metadata + prayer_request.requester_name = ( + f"{prayer_request.requester.first_name} {prayer_request.requester.last_name}" + if not prayer_request.is_anonymous + else "Anonymous" + ) + + prayer_request.prayer_count = ( + db.query(Prayer).filter(Prayer.prayer_request_id == prayer_request.id).count() + ) + + prayer_request.has_prayed = ( + db.query(Prayer) + .filter( + Prayer.prayer_request_id == prayer_request.id, + Prayer.prayer_warrior_id == current_user.id, + ) + .first() + is not None + ) + + if prayer_request.ministry_id: + prayer_request.ministry_name = prayer_request.ministry.name + + return prayer_request + + +@router.put("/{request_id}", response_model=PrayerRequestResponse) +def update_prayer_request( + request_id: int, + prayer_update: PrayerRequestUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update prayer request (owner only)""" + + prayer_request = ( + db.query(PrayerRequest).filter(PrayerRequest.id == request_id).first() + ) + + if not prayer_request: + raise HTTPException(status_code=404, detail="Prayer request not found") + + if prayer_request.requester_id != current_user.id: + raise HTTPException( + status_code=403, detail="Can only update own prayer requests" + ) + + # Handle status changes + update_data = prayer_update.dict(exclude_unset=True) + if "status" in update_data and update_data["status"] == PrayerStatus.ANSWERED: + if not update_data.get("answer_description"): + raise HTTPException( + status_code=400, + detail="Answer description required when marking as answered", + ) + update_data["answered_at"] = datetime.utcnow() + + # Update fields + for field, value in update_data.items(): + setattr(prayer_request, field, value) + + db.commit() + db.refresh(prayer_request) + + return prayer_request + + +@router.post("/{request_id}/pray", response_model=PrayerResponse) +def pray_for_request( + request_id: int, + prayer: PrayerCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Pray for a prayer request""" + + prayer_request = ( + db.query(PrayerRequest).filter(PrayerRequest.id == request_id).first() + ) + + if not prayer_request: + raise HTTPException(status_code=404, detail="Prayer request not found") + + if not can_view_prayer_request(prayer_request, current_user, db): + raise HTTPException( + status_code=403, detail="Access denied to this prayer request" + ) + + if prayer_request.requester_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot pray for your own request") + + # Check if already prayed + existing_prayer = ( + db.query(Prayer) + .filter( + Prayer.prayer_request_id == request_id, + Prayer.prayer_warrior_id == current_user.id, + ) + .first() + ) + + if existing_prayer: + raise HTTPException(status_code=400, detail="Already prayed for this request") + + db_prayer = Prayer( + prayer_request_id=request_id, + prayer_warrior_id=current_user.id, + message=prayer.message, + ) + + db.add(db_prayer) + db.commit() + db.refresh(db_prayer) + + db_prayer.prayer_warrior_name = ( + f"{current_user.first_name} {current_user.last_name}" + ) + + return db_prayer + + +@router.get("/{request_id}/prayers", response_model=List[PrayerResponse]) +def get_prayers_for_request( + request_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get prayers for a specific request""" + + prayer_request = ( + db.query(PrayerRequest).filter(PrayerRequest.id == request_id).first() + ) + + if not prayer_request: + raise HTTPException(status_code=404, detail="Prayer request not found") + + if not can_view_prayer_request(prayer_request, current_user, db): + raise HTTPException( + status_code=403, detail="Access denied to this prayer request" + ) + + prayers = ( + db.query(Prayer) + .filter(Prayer.prayer_request_id == request_id) + .order_by(Prayer.prayed_at.desc()) + .all() + ) + + for prayer in prayers: + prayer.prayer_warrior_name = ( + f"{prayer.prayer_warrior.first_name} {prayer.prayer_warrior.last_name}" + ) + + return prayers + + +@router.get("/stats/personal", response_model=PrayerStats) +def get_personal_prayer_stats( + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + """Get personal prayer statistics""" + + total_requests = db.query(PrayerRequest).count() + active_requests = ( + db.query(PrayerRequest) + .filter(PrayerRequest.status == PrayerStatus.ACTIVE) + .count() + ) + answered_requests = ( + db.query(PrayerRequest) + .filter(PrayerRequest.status == PrayerStatus.ANSWERED) + .count() + ) + + prayers_offered = ( + db.query(Prayer).filter(Prayer.prayer_warrior_id == current_user.id).count() + ) + requests_created = ( + db.query(PrayerRequest) + .filter(PrayerRequest.requester_id == current_user.id) + .count() + ) + + return PrayerStats( + total_requests=total_requests, + active_requests=active_requests, + answered_requests=answered_requests, + prayers_offered=prayers_offered, + requests_created=requests_created, + ) diff --git a/app/api/uploads.py b/app/api/uploads.py new file mode 100644 index 0000000..f1337ba --- /dev/null +++ b/app/api/uploads.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter, Depends, UploadFile, File, HTTPException +from fastapi.responses import FileResponse +from pathlib import Path +from app.models.user import User +from app.api.auth import get_current_user +from app.core.upload import save_upload_file, delete_file + +router = APIRouter() + + +@router.post("/profile-picture") +async def upload_profile_picture( + file: UploadFile = File(...), current_user: User = Depends(get_current_user) +): + """Upload a profile picture""" + try: + file_path = await save_upload_file(file, "profile_pictures") + + # Delete old profile picture if exists + if current_user.profile_picture: + delete_file(current_user.profile_picture) + + # Update user's profile picture in database would happen here + # For now, just return the path + return { + "file_path": file_path, + "message": "Profile picture uploaded successfully", + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") + + +@router.post("/event-image") +async def upload_event_image( + file: UploadFile = File(...), current_user: User = Depends(get_current_user) +): + """Upload an event image""" + try: + file_path = await save_upload_file(file, "event_images") + return {"file_path": file_path, "message": "Event image uploaded successfully"} + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") + + +@router.post("/post-image") +async def upload_post_image( + file: UploadFile = File(...), current_user: User = Depends(get_current_user) +): + """Upload a post image""" + try: + file_path = await save_upload_file(file, "post_images") + return {"file_path": file_path, "message": "Post image uploaded successfully"} + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") + + +@router.get("/serve/{folder}/{filename}") +async def serve_file(folder: str, filename: str): + """Serve uploaded files""" + file_path = Path(f"/app/storage/uploads/{folder}/{filename}") + + if not file_path.exists(): + raise HTTPException(status_code=404, detail="File not found") + + return FileResponse(file_path) diff --git a/app/core/__init__.py b/app/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/core/upload.py b/app/core/upload.py new file mode 100644 index 0000000..9910289 --- /dev/null +++ b/app/core/upload.py @@ -0,0 +1,71 @@ +import uuid +from pathlib import Path +from fastapi import UploadFile, HTTPException +from PIL import Image +import aiofiles + +UPLOAD_DIR = Path("/app/storage/uploads") +ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} +MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB + + +async def save_upload_file(upload_file: UploadFile, subfolder: str = "general") -> str: + """Save uploaded file and return the file path""" + + if upload_file.size > MAX_FILE_SIZE: + raise HTTPException(status_code=413, detail="File too large") + + if upload_file.content_type not in ALLOWED_IMAGE_TYPES: + raise HTTPException(status_code=400, detail="Invalid file type") + + # Create directory if it doesn't exist + upload_path = UPLOAD_DIR / subfolder + upload_path.mkdir(parents=True, exist_ok=True) + + # Generate unique filename + file_extension = upload_file.filename.split(".")[-1] + unique_filename = f"{uuid.uuid4()}.{file_extension}" + file_path = upload_path / unique_filename + + # Save file + async with aiofiles.open(file_path, "wb") as f: + content = await upload_file.read() + await f.write(content) + + # Optimize image if it's an image + if upload_file.content_type.startswith("image/"): + optimize_image(str(file_path)) + + return f"/uploads/{subfolder}/{unique_filename}" + + +def optimize_image( + file_path: str, max_width: int = 1024, max_height: int = 1024, quality: int = 85 +): + """Optimize image size and quality""" + try: + with Image.open(file_path) as img: + # Convert to RGB if needed + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + + # Resize if too large + if img.width > max_width or img.height > max_height: + img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + + # Save with optimization + img.save(file_path, optimize=True, quality=quality) + except Exception as e: + print(f"Image optimization failed: {e}") + + +def delete_file(file_path: str) -> bool: + """Delete a file from storage""" + try: + full_path = Path("/app/storage") / file_path.lstrip("/") + if full_path.exists(): + full_path.unlink() + return True + return False + except Exception: + return False diff --git a/app/db/__init__.py b/app/db/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/models/__init__.py b/app/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/models/attendance.py b/app/models/attendance.py new file mode 100644 index 0000000..48a8d0c --- /dev/null +++ b/app/models/attendance.py @@ -0,0 +1,124 @@ +from sqlalchemy import ( + Boolean, + Column, + Integer, + String, + DateTime, + Text, + ForeignKey, + Enum, + Date, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.db.base import Base +import enum + + +class ServiceType(enum.Enum): + SUNDAY_SERVICE = "sunday_service" + BIBLE_STUDY = "bible_study" + PRAYER_MEETING = "prayer_meeting" + YOUTH_SERVICE = "youth_service" + WOMEN_MEETING = "women_meeting" + MEN_MEETING = "men_meeting" + SPECIAL_SERVICE = "special_service" + OTHER = "other" + + +class AttendanceStatus(enum.Enum): + PRESENT = "present" + ABSENT = "absent" + LATE = "late" + EXCUSED = "excused" + + +class Service(Base): + __tablename__ = "services" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + description = Column(Text, nullable=True) + service_type = Column(Enum(ServiceType), nullable=False) + service_date = Column(Date, nullable=False) + start_time = Column(DateTime, nullable=False) + end_time = Column(DateTime, nullable=True) + location = Column(String, nullable=True) + minister_id = Column(Integer, ForeignKey("users.id"), nullable=True) + is_active = Column(Boolean, default=True) + created_by = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + minister = relationship("User", foreign_keys=[minister_id]) + creator = relationship("User", foreign_keys=[created_by]) + attendance_records = relationship( + "AttendanceRecord", back_populates="service", cascade="all, delete-orphan" + ) + + +class AttendanceRecord(Base): + __tablename__ = "attendance_records" + + id = Column(Integer, primary_key=True, index=True) + service_id = Column(Integer, ForeignKey("services.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + status = Column(Enum(AttendanceStatus), default=AttendanceStatus.PRESENT) + check_in_time = Column(DateTime, nullable=True) + check_out_time = Column(DateTime, nullable=True) + notes = Column(Text, nullable=True) + recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False) + recorded_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + service = relationship("Service", back_populates="attendance_records") + user = relationship("User", foreign_keys=[user_id]) + recorder = relationship("User", foreign_keys=[recorded_by]) + + +class AttendanceGoal(Base): + __tablename__ = "attendance_goals" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + service_type = Column(Enum(ServiceType), nullable=False) + target_percentage = Column(Integer, default=80) # Target attendance percentage + start_date = Column(Date, nullable=False) + end_date = Column(Date, nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + user = relationship("User") + + +class FamilyGroup(Base): + __tablename__ = "family_groups" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + head_of_family_id = Column(Integer, ForeignKey("users.id"), nullable=False) + description = Column(Text, nullable=True) + created_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + head_of_family = relationship("User") + members = relationship( + "FamilyMember", back_populates="family_group", cascade="all, delete-orphan" + ) + + +class FamilyMember(Base): + __tablename__ = "family_members" + + id = Column(Integer, primary_key=True, index=True) + family_group_id = Column(Integer, ForeignKey("family_groups.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + name = Column(String, nullable=False) # For non-registered family members + relationship = Column(String, nullable=True) # spouse, child, parent, etc. + date_of_birth = Column(Date, nullable=True) + is_registered_user = Column(Boolean, default=False) + + # Relationships + family_group = relationship("FamilyGroup", back_populates="members") + user = relationship("User") diff --git a/app/models/donation.py b/app/models/donation.py new file mode 100644 index 0000000..6cb4570 --- /dev/null +++ b/app/models/donation.py @@ -0,0 +1,106 @@ +from sqlalchemy import ( + Boolean, + Column, + Integer, + String, + DateTime, + Text, + ForeignKey, + Enum, + Numeric, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.db.base import Base +import enum + + +class DonationType(enum.Enum): + TITHE = "tithe" + OFFERING = "offering" + SPECIAL_OFFERING = "special_offering" + BUILDING_FUND = "building_fund" + MISSIONS = "missions" + CHARITY = "charity" + OTHER = "other" + + +class PaymentMethod(enum.Enum): + CASH = "cash" + CHECK = "check" + CARD = "card" + BANK_TRANSFER = "bank_transfer" + MOBILE_MONEY = "mobile_money" + ONLINE = "online" + + +class Donation(Base): + __tablename__ = "donations" + + id = Column(Integer, primary_key=True, index=True) + donor_id = Column(Integer, ForeignKey("users.id"), nullable=False) + amount = Column(Numeric(10, 2), nullable=False) + currency = Column(String, default="USD") + donation_type = Column(Enum(DonationType), nullable=False) + payment_method = Column(Enum(PaymentMethod), nullable=False) + reference_number = Column(String, nullable=True) + notes = Column(Text, nullable=True) + is_anonymous = Column(Boolean, default=False) + is_recurring = Column(Boolean, default=False) + recurring_frequency = Column(String, nullable=True) # weekly, monthly, yearly + next_due_date = Column(DateTime, nullable=True) + donation_date = Column(DateTime, nullable=False, default=func.now()) + created_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + donor = relationship("User", back_populates="donations") + + +class DonationGoal(Base): + __tablename__ = "donation_goals" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + description = Column(Text, nullable=True) + target_amount = Column(Numeric(10, 2), nullable=False) + current_amount = Column(Numeric(10, 2), default=0) + currency = Column(String, default="USD") + start_date = Column(DateTime, nullable=False) + end_date = Column(DateTime, nullable=False) + is_active = Column(Boolean, default=True) + created_by = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + creator = relationship("User") + donations = relationship("GoalDonation", back_populates="goal") + + +class GoalDonation(Base): + __tablename__ = "goal_donations" + + id = Column(Integer, primary_key=True, index=True) + goal_id = Column(Integer, ForeignKey("donation_goals.id"), nullable=False) + donation_id = Column(Integer, ForeignKey("donations.id"), nullable=False) + amount = Column(Numeric(10, 2), nullable=False) + created_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + goal = relationship("DonationGoal", back_populates="donations") + donation = relationship("Donation") + + +class TithePledge(Base): + __tablename__ = "tithe_pledges" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + monthly_amount = Column(Numeric(10, 2), nullable=False) + currency = Column(String, default="USD") + start_date = Column(DateTime, nullable=False) + end_date = Column(DateTime, nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + user = relationship("User") diff --git a/app/models/message.py b/app/models/message.py new file mode 100644 index 0000000..e4c53d7 --- /dev/null +++ b/app/models/message.py @@ -0,0 +1,109 @@ +from sqlalchemy import ( + Boolean, + Column, + Integer, + String, + DateTime, + Text, + ForeignKey, + Enum, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.db.base import Base +import enum + + +class MessageType(enum.Enum): + TEXT = "text" + IMAGE = "image" + FILE = "file" + + +class Conversation(Base): + __tablename__ = "conversations" + + id = Column(Integer, primary_key=True, index=True) + participant_1_id = Column(Integer, ForeignKey("users.id"), nullable=False) + participant_2_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, nullable=False, default=func.now()) + updated_at = Column( + DateTime, nullable=False, default=func.now(), onupdate=func.now() + ) + + # Relationships + participant_1 = relationship("User", foreign_keys=[participant_1_id]) + participant_2 = relationship("User", foreign_keys=[participant_2_id]) + messages = relationship( + "Message", back_populates="conversation", cascade="all, delete-orphan" + ) + + +class Message(Base): + __tablename__ = "messages" + + id = Column(Integer, primary_key=True, index=True) + conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False) + sender_id = Column(Integer, ForeignKey("users.id"), nullable=False) + receiver_id = Column(Integer, ForeignKey("users.id"), nullable=False) + message_type = Column(Enum(MessageType), default=MessageType.TEXT) + content = Column(Text, nullable=False) + file_url = Column(String, nullable=True) + is_read = Column(Boolean, default=False) + read_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + conversation = relationship("Conversation", back_populates="messages") + sender = relationship("User", foreign_keys=[sender_id]) + receiver = relationship("User", foreign_keys=[receiver_id]) + + +class GroupChat(Base): + __tablename__ = "group_chats" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + description = Column(Text, nullable=True) + created_by = Column(Integer, ForeignKey("users.id"), nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + creator = relationship("User") + members = relationship( + "GroupChatMember", back_populates="group_chat", cascade="all, delete-orphan" + ) + messages = relationship( + "GroupMessage", back_populates="group_chat", cascade="all, delete-orphan" + ) + + +class GroupChatMember(Base): + __tablename__ = "group_chat_members" + + id = Column(Integer, primary_key=True, index=True) + group_chat_id = Column(Integer, ForeignKey("group_chats.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + is_admin = Column(Boolean, default=False) + joined_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + group_chat = relationship("GroupChat", back_populates="members") + user = relationship("User") + + +class GroupMessage(Base): + __tablename__ = "group_messages" + + id = Column(Integer, primary_key=True, index=True) + group_chat_id = Column(Integer, ForeignKey("group_chats.id"), nullable=False) + sender_id = Column(Integer, ForeignKey("users.id"), nullable=False) + message_type = Column(Enum(MessageType), default=MessageType.TEXT) + content = Column(Text, nullable=False) + file_url = Column(String, nullable=True) + created_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + group_chat = relationship("GroupChat", back_populates="messages") + sender = relationship("User") diff --git a/app/models/ministry.py b/app/models/ministry.py new file mode 100644 index 0000000..e8aa9aa --- /dev/null +++ b/app/models/ministry.py @@ -0,0 +1,99 @@ +from sqlalchemy import ( + Boolean, + Column, + Integer, + String, + DateTime, + Text, + ForeignKey, + Enum, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.db.base import Base +import enum + + +class MinistryRole(enum.Enum): + LEADER = "leader" + CO_LEADER = "co_leader" + MEMBER = "member" + VOLUNTEER = "volunteer" + + +class Ministry(Base): + __tablename__ = "ministries" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + description = Column(Text, nullable=True) + vision = Column(Text, nullable=True) + mission = Column(Text, nullable=True) + meeting_day = Column(String, nullable=True) + meeting_time = Column(String, nullable=True) + meeting_location = Column(String, nullable=True) + leader_id = Column(Integer, ForeignKey("users.id"), nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, nullable=False, default=func.now()) + updated_at = Column( + DateTime, nullable=False, default=func.now(), onupdate=func.now() + ) + + # Relationships + leader = relationship("User") + members = relationship( + "MinistryMember", back_populates="ministry", cascade="all, delete-orphan" + ) + activities = relationship( + "MinistryActivity", back_populates="ministry", cascade="all, delete-orphan" + ) + + +class MinistryMember(Base): + __tablename__ = "ministry_members" + + id = Column(Integer, primary_key=True, index=True) + ministry_id = Column(Integer, ForeignKey("ministries.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + role = Column(Enum(MinistryRole), default=MinistryRole.MEMBER) + joined_at = Column(DateTime, nullable=False, default=func.now()) + is_active = Column(Boolean, default=True) + + # Relationships + ministry = relationship("Ministry", back_populates="members") + user = relationship("User") + + +class MinistryActivity(Base): + __tablename__ = "ministry_activities" + + id = Column(Integer, primary_key=True, index=True) + ministry_id = Column(Integer, ForeignKey("ministries.id"), nullable=False) + title = Column(String, nullable=False) + description = Column(Text, nullable=True) + activity_date = Column(DateTime, nullable=False) + location = Column(String, nullable=True) + created_by = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + ministry = relationship("Ministry", back_populates="activities") + creator = relationship("User") + + +class MinistryRequest(Base): + __tablename__ = "ministry_requests" + + id = Column(Integer, primary_key=True, index=True) + ministry_id = Column(Integer, ForeignKey("ministries.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + message = Column(Text, nullable=True) + status = Column(String, default="pending") # pending, approved, rejected + requested_at = Column(DateTime, nullable=False, default=func.now()) + responded_at = Column(DateTime, nullable=True) + responded_by = Column(Integer, ForeignKey("users.id"), nullable=True) + + # Relationships + ministry = relationship("Ministry") + user = relationship("User", foreign_keys=[user_id]) + responder = relationship("User", foreign_keys=[responded_by]) diff --git a/app/models/notification.py b/app/models/notification.py new file mode 100644 index 0000000..914604e --- /dev/null +++ b/app/models/notification.py @@ -0,0 +1,67 @@ +from sqlalchemy import ( + Boolean, + Column, + Integer, + String, + DateTime, + Text, + ForeignKey, + Enum, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.db.base import Base +import enum + + +class NotificationType(enum.Enum): + CONNECTION_REQUEST = "connection_request" + CONNECTION_ACCEPTED = "connection_accepted" + POST_LIKE = "post_like" + POST_COMMENT = "post_comment" + EVENT_INVITATION = "event_invitation" + EVENT_REMINDER = "event_reminder" + PRAYER_REQUEST = "prayer_request" + MINISTRY_ASSIGNMENT = "ministry_assignment" + DONATION_REMINDER = "donation_reminder" + BIRTHDAY_REMINDER = "birthday_reminder" + ANNOUNCEMENT = "announcement" + + +class Notification(Base): + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True, index=True) + recipient_id = Column(Integer, ForeignKey("users.id"), nullable=False) + sender_id = Column(Integer, ForeignKey("users.id"), nullable=True) + type = Column(Enum(NotificationType), nullable=False) + title = Column(String, nullable=False) + message = Column(Text, nullable=False) + data = Column(Text, nullable=True) # JSON data for additional context + is_read = Column(Boolean, default=False) + created_at = Column(DateTime, nullable=False, default=func.now()) + read_at = Column(DateTime, nullable=True) + + # Relationships + recipient = relationship("User", foreign_keys=[recipient_id]) + sender = relationship("User", foreign_keys=[sender_id]) + + +class NotificationSettings(Base): + __tablename__ = "notification_settings" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + email_notifications = Column(Boolean, default=True) + push_notifications = Column(Boolean, default=True) + connection_requests = Column(Boolean, default=True) + post_interactions = Column(Boolean, default=True) + event_reminders = Column(Boolean, default=True) + prayer_requests = Column(Boolean, default=True) + ministry_updates = Column(Boolean, default=True) + donation_reminders = Column(Boolean, default=True) + birthday_reminders = Column(Boolean, default=True) + announcements = Column(Boolean, default=True) + + # Relationships + user = relationship("User", back_populates="notification_settings") diff --git a/app/models/prayer.py b/app/models/prayer.py new file mode 100644 index 0000000..5569422 --- /dev/null +++ b/app/models/prayer.py @@ -0,0 +1,112 @@ +from sqlalchemy import ( + Boolean, + Column, + Integer, + String, + DateTime, + Text, + ForeignKey, + Enum, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.db.base import Base +import enum + + +class PrayerPrivacyLevel(enum.Enum): + PUBLIC = "public" + CONNECTIONS_ONLY = "connections_only" + MINISTRY_ONLY = "ministry_only" + PRIVATE = "private" + + +class PrayerStatus(enum.Enum): + ACTIVE = "active" + ANSWERED = "answered" + CLOSED = "closed" + + +class PrayerCategory(enum.Enum): + HEALTH = "health" + FAMILY = "family" + FINANCES = "finances" + RELATIONSHIPS = "relationships" + WORK = "work" + SPIRITUAL = "spiritual" + CHURCH = "church" + WORLD = "world" + OTHER = "other" + + +class PrayerRequest(Base): + __tablename__ = "prayer_requests" + + id = Column(Integer, primary_key=True, index=True) + requester_id = Column(Integer, ForeignKey("users.id"), nullable=False) + title = Column(String, nullable=False) + description = Column(Text, nullable=False) + category = Column(Enum(PrayerCategory), default=PrayerCategory.OTHER) + privacy_level = Column(Enum(PrayerPrivacyLevel), default=PrayerPrivacyLevel.PUBLIC) + status = Column(Enum(PrayerStatus), default=PrayerStatus.ACTIVE) + ministry_id = Column(Integer, ForeignKey("ministries.id"), nullable=True) + is_anonymous = Column(Boolean, default=False) + answer_description = Column(Text, nullable=True) + answered_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, nullable=False, default=func.now()) + updated_at = Column( + DateTime, nullable=False, default=func.now(), onupdate=func.now() + ) + + # Relationships + requester = relationship("User", back_populates="prayer_requests") + ministry = relationship("Ministry") + prayers = relationship( + "Prayer", back_populates="prayer_request", cascade="all, delete-orphan" + ) + + +class Prayer(Base): + __tablename__ = "prayers" + + id = Column(Integer, primary_key=True, index=True) + prayer_request_id = Column( + Integer, ForeignKey("prayer_requests.id"), nullable=False + ) + prayer_warrior_id = Column(Integer, ForeignKey("users.id"), nullable=False) + message = Column(Text, nullable=True) + prayed_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + prayer_request = relationship("PrayerRequest", back_populates="prayers") + prayer_warrior = relationship("User") + + +class PrayerGroup(Base): + __tablename__ = "prayer_groups" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + description = Column(Text, nullable=True) + leader_id = Column(Integer, ForeignKey("users.id"), nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + leader = relationship("User") + members = relationship( + "PrayerGroupMember", back_populates="prayer_group", cascade="all, delete-orphan" + ) + + +class PrayerGroupMember(Base): + __tablename__ = "prayer_group_members" + + id = Column(Integer, primary_key=True, index=True) + prayer_group_id = Column(Integer, ForeignKey("prayer_groups.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + joined_at = Column(DateTime, nullable=False, default=func.now()) + + # Relationships + prayer_group = relationship("PrayerGroup", back_populates="members") + user = relationship("User") diff --git a/app/models/user.py b/app/models/user.py index 51185b3..eedd384 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -34,6 +34,13 @@ class User(Base): likes = relationship("Like", back_populates="user") event_registrations = relationship("EventRegistration", back_populates="user") + # New relationships for enhanced features + donations = relationship("Donation", back_populates="donor") + prayer_requests = relationship("PrayerRequest", back_populates="requester") + notification_settings = relationship( + "NotificationSettings", back_populates="user", uselist=False + ) + class Connection(Base): __tablename__ = "connections" diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/schemas/attendance.py b/app/schemas/attendance.py new file mode 100644 index 0000000..81807e4 --- /dev/null +++ b/app/schemas/attendance.py @@ -0,0 +1,158 @@ +from pydantic import BaseModel +from datetime import datetime, date +from typing import Optional +from app.models.attendance import ServiceType, AttendanceStatus + + +class ServiceBase(BaseModel): + title: str + description: Optional[str] = None + service_type: ServiceType + service_date: date + start_time: datetime + end_time: Optional[datetime] = None + location: Optional[str] = None + minister_id: Optional[int] = None + + +class ServiceCreate(ServiceBase): + pass + + +class ServiceUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + service_type: Optional[ServiceType] = None + service_date: Optional[date] = None + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + location: Optional[str] = None + minister_id: Optional[int] = None + is_active: Optional[bool] = None + + +class ServiceResponse(ServiceBase): + id: int + is_active: bool + created_by: int + created_at: datetime + creator_name: Optional[str] = None + minister_name: Optional[str] = None + attendance_count: int = 0 + + class Config: + from_attributes = True + + +class AttendanceRecordBase(BaseModel): + user_id: int + status: AttendanceStatus = AttendanceStatus.PRESENT + check_in_time: Optional[datetime] = None + check_out_time: Optional[datetime] = None + notes: Optional[str] = None + + +class AttendanceRecordCreate(AttendanceRecordBase): + pass + + +class AttendanceRecordUpdate(BaseModel): + status: Optional[AttendanceStatus] = None + check_in_time: Optional[datetime] = None + check_out_time: Optional[datetime] = None + notes: Optional[str] = None + + +class AttendanceRecordResponse(AttendanceRecordBase): + id: int + service_id: int + recorded_by: int + recorded_at: datetime + user_name: Optional[str] = None + recorder_name: Optional[str] = None + + class Config: + from_attributes = True + + +class AttendanceGoalBase(BaseModel): + service_type: ServiceType + target_percentage: int = 80 + start_date: date + end_date: date + + +class AttendanceGoalCreate(AttendanceGoalBase): + pass + + +class AttendanceGoalResponse(AttendanceGoalBase): + id: int + user_id: int + is_active: bool + created_at: datetime + current_percentage: float = 0.0 + + class Config: + from_attributes = True + + +class FamilyGroupBase(BaseModel): + name: str + description: Optional[str] = None + + +class FamilyGroupCreate(FamilyGroupBase): + pass + + +class FamilyGroupResponse(FamilyGroupBase): + id: int + head_of_family_id: int + created_at: datetime + head_of_family_name: Optional[str] = None + member_count: int = 0 + + class Config: + from_attributes = True + + +class FamilyMemberBase(BaseModel): + name: str + relationship: Optional[str] = None + date_of_birth: Optional[date] = None + user_id: Optional[int] = None + + +class FamilyMemberCreate(FamilyMemberBase): + pass + + +class FamilyMemberResponse(FamilyMemberBase): + id: int + family_group_id: int + is_registered_user: bool + + class Config: + from_attributes = True + + +class AttendanceStats(BaseModel): + total_services: int + services_attended: int + attendance_percentage: float + most_attended_service_type: Optional[ServiceType] = None + current_streak: int = 0 + longest_streak: int = 0 + + +class ServiceAttendanceSummary(BaseModel): + service_id: int + service_title: str + service_date: date + service_type: ServiceType + total_attendees: int + present_count: int + absent_count: int + late_count: int + attendance_rate: float diff --git a/app/schemas/donation.py b/app/schemas/donation.py new file mode 100644 index 0000000..2a5fd0f --- /dev/null +++ b/app/schemas/donation.py @@ -0,0 +1,137 @@ +from pydantic import BaseModel, validator +from datetime import datetime +from typing import Optional +from decimal import Decimal +from app.models.donation import DonationType, PaymentMethod + + +class DonationBase(BaseModel): + amount: Decimal + currency: str = "USD" + donation_type: DonationType + payment_method: PaymentMethod + reference_number: Optional[str] = None + notes: Optional[str] = None + is_anonymous: bool = False + is_recurring: bool = False + recurring_frequency: Optional[str] = None + donation_date: Optional[datetime] = None + + @validator("amount") + def amount_must_be_positive(cls, v): + if v <= 0: + raise ValueError("Amount must be positive") + return v + + +class DonationCreate(DonationBase): + pass + + +class DonationResponse(DonationBase): + id: int + donor_id: int + next_due_date: Optional[datetime] = None + created_at: datetime + donor_name: Optional[str] = None + + class Config: + from_attributes = True + + +class DonationGoalBase(BaseModel): + title: str + description: Optional[str] = None + target_amount: Decimal + currency: str = "USD" + start_date: datetime + end_date: datetime + + @validator("target_amount") + def target_amount_must_be_positive(cls, v): + if v <= 0: + raise ValueError("Target amount must be positive") + return v + + @validator("end_date") + def end_date_must_be_after_start_date(cls, v, values): + if "start_date" in values and v <= values["start_date"]: + raise ValueError("End date must be after start date") + return v + + +class DonationGoalCreate(DonationGoalBase): + pass + + +class DonationGoalUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + target_amount: Optional[Decimal] = None + end_date: Optional[datetime] = None + is_active: Optional[bool] = None + + +class DonationGoalResponse(DonationGoalBase): + id: int + current_amount: Decimal + is_active: bool + created_by: int + created_at: datetime + creator_name: Optional[str] = None + progress_percentage: float = 0.0 + days_remaining: int = 0 + + class Config: + from_attributes = True + + +class TithePledgeBase(BaseModel): + monthly_amount: Decimal + currency: str = "USD" + start_date: datetime + end_date: Optional[datetime] = None + + @validator("monthly_amount") + def monthly_amount_must_be_positive(cls, v): + if v <= 0: + raise ValueError("Monthly amount must be positive") + return v + + +class TithePledgeCreate(TithePledgeBase): + pass + + +class TithePledgeUpdate(BaseModel): + monthly_amount: Optional[Decimal] = None + end_date: Optional[datetime] = None + is_active: Optional[bool] = None + + +class TithePledgeResponse(TithePledgeBase): + id: int + user_id: int + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + + +class DonationStats(BaseModel): + total_donations: Decimal + total_tithes: Decimal + total_offerings: Decimal + donation_count: int + average_donation: Decimal + currency: str = "USD" + + +class MonthlyDonationSummary(BaseModel): + month: str + year: int + total_amount: Decimal + donation_count: int + tithe_amount: Decimal + offering_amount: Decimal diff --git a/app/schemas/message.py b/app/schemas/message.py new file mode 100644 index 0000000..082b2f3 --- /dev/null +++ b/app/schemas/message.py @@ -0,0 +1,98 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, List +from app.models.message import MessageType + + +class MessageBase(BaseModel): + content: str + message_type: MessageType = MessageType.TEXT + file_url: Optional[str] = None + + +class MessageCreate(MessageBase): + receiver_id: int + + +class MessageResponse(MessageBase): + id: int + conversation_id: int + sender_id: int + receiver_id: int + is_read: bool + read_at: Optional[datetime] = None + created_at: datetime + sender_name: Optional[str] = None + + class Config: + from_attributes = True + + +class ConversationResponse(BaseModel): + id: int + participant_1_id: int + participant_2_id: int + created_at: datetime + updated_at: datetime + other_participant_name: Optional[str] = None + other_participant_id: Optional[int] = None + last_message: Optional[str] = None + unread_count: int = 0 + + class Config: + from_attributes = True + + +class GroupChatBase(BaseModel): + name: str + description: Optional[str] = None + + +class GroupChatCreate(GroupChatBase): + member_ids: List[int] = [] + + +class GroupChatResponse(GroupChatBase): + id: int + created_by: int + is_active: bool + created_at: datetime + creator_name: Optional[str] = None + member_count: int = 0 + last_message: Optional[str] = None + + class Config: + from_attributes = True + + +class GroupChatMemberResponse(BaseModel): + id: int + group_chat_id: int + user_id: int + is_admin: bool + joined_at: datetime + user_name: Optional[str] = None + + class Config: + from_attributes = True + + +class GroupMessageBase(BaseModel): + content: str + message_type: MessageType = MessageType.TEXT + file_url: Optional[str] = None + + +class GroupMessageCreate(GroupMessageBase): + pass + + +class GroupMessageResponse(GroupMessageBase): + id: int + group_chat_id: int + sender_id: int + created_at: datetime + sender_name: Optional[str] = None + + class Config: + from_attributes = True diff --git a/app/schemas/ministry.py b/app/schemas/ministry.py new file mode 100644 index 0000000..46be0fa --- /dev/null +++ b/app/schemas/ministry.py @@ -0,0 +1,99 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional +from app.models.ministry import MinistryRole + + +class MinistryBase(BaseModel): + name: str + description: Optional[str] = None + vision: Optional[str] = None + mission: Optional[str] = None + meeting_day: Optional[str] = None + meeting_time: Optional[str] = None + meeting_location: Optional[str] = None + + +class MinistryCreate(MinistryBase): + pass + + +class MinistryUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + vision: Optional[str] = None + mission: Optional[str] = None + meeting_day: Optional[str] = None + meeting_time: Optional[str] = None + meeting_location: Optional[str] = None + + +class MinistryResponse(MinistryBase): + id: int + leader_id: int + is_active: bool + created_at: datetime + updated_at: datetime + leader_name: Optional[str] = None + member_count: int = 0 + is_member: bool = False + user_role: Optional[MinistryRole] = None + + class Config: + from_attributes = True + + +class MinistryMemberResponse(BaseModel): + id: int + ministry_id: int + user_id: int + role: MinistryRole + joined_at: datetime + is_active: bool + user_name: Optional[str] = None + ministry_name: Optional[str] = None + + class Config: + from_attributes = True + + +class MinistryActivityBase(BaseModel): + title: str + description: Optional[str] = None + activity_date: datetime + location: Optional[str] = None + + +class MinistryActivityCreate(MinistryActivityBase): + pass + + +class MinistryActivityResponse(MinistryActivityBase): + id: int + ministry_id: int + created_by: int + created_at: datetime + creator_name: Optional[str] = None + + class Config: + from_attributes = True + + +class MinistryJoinRequest(BaseModel): + message: Optional[str] = None + + +class MinistryRequestResponse(BaseModel): + id: int + ministry_id: int + user_id: int + message: Optional[str] = None + status: str + requested_at: datetime + responded_at: Optional[datetime] = None + responded_by: Optional[int] = None + user_name: Optional[str] = None + ministry_name: Optional[str] = None + + class Config: + from_attributes = True diff --git a/app/schemas/notification.py b/app/schemas/notification.py new file mode 100644 index 0000000..d7c3adc --- /dev/null +++ b/app/schemas/notification.py @@ -0,0 +1,52 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional +from app.models.notification import NotificationType + + +class NotificationResponse(BaseModel): + id: int + recipient_id: int + sender_id: Optional[int] = None + type: NotificationType + title: str + message: str + data: Optional[str] = None + is_read: bool + created_at: datetime + read_at: Optional[datetime] = None + sender_name: Optional[str] = None + + class Config: + from_attributes = True + + +class NotificationSettingsResponse(BaseModel): + id: int + user_id: int + email_notifications: bool + push_notifications: bool + connection_requests: bool + post_interactions: bool + event_reminders: bool + prayer_requests: bool + ministry_updates: bool + donation_reminders: bool + birthday_reminders: bool + announcements: bool + + class Config: + from_attributes = True + + +class NotificationSettingsUpdate(BaseModel): + email_notifications: Optional[bool] = None + push_notifications: Optional[bool] = None + connection_requests: Optional[bool] = None + post_interactions: Optional[bool] = None + event_reminders: Optional[bool] = None + prayer_requests: Optional[bool] = None + ministry_updates: Optional[bool] = None + donation_reminders: Optional[bool] = None + birthday_reminders: Optional[bool] = None + announcements: Optional[bool] = None diff --git a/app/schemas/prayer.py b/app/schemas/prayer.py new file mode 100644 index 0000000..63e9743 --- /dev/null +++ b/app/schemas/prayer.py @@ -0,0 +1,103 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, List +from app.models.prayer import PrayerPrivacyLevel, PrayerStatus, PrayerCategory + + +class PrayerRequestBase(BaseModel): + title: str + description: str + category: PrayerCategory = PrayerCategory.OTHER + privacy_level: PrayerPrivacyLevel = PrayerPrivacyLevel.PUBLIC + ministry_id: Optional[int] = None + is_anonymous: bool = False + + +class PrayerRequestCreate(PrayerRequestBase): + pass + + +class PrayerRequestUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + category: Optional[PrayerCategory] = None + privacy_level: Optional[PrayerPrivacyLevel] = None + status: Optional[PrayerStatus] = None + answer_description: Optional[str] = None + + +class PrayerRequestResponse(PrayerRequestBase): + id: int + requester_id: int + status: PrayerStatus + answer_description: Optional[str] = None + answered_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + requester_name: Optional[str] = None + ministry_name: Optional[str] = None + prayer_count: int = 0 + has_prayed: bool = False + + class Config: + from_attributes = True + + +class PrayerBase(BaseModel): + message: Optional[str] = None + + +class PrayerCreate(PrayerBase): + pass + + +class PrayerResponse(PrayerBase): + id: int + prayer_request_id: int + prayer_warrior_id: int + prayed_at: datetime + prayer_warrior_name: Optional[str] = None + + class Config: + from_attributes = True + + +class PrayerGroupBase(BaseModel): + name: str + description: Optional[str] = None + + +class PrayerGroupCreate(PrayerGroupBase): + member_ids: List[int] = [] + + +class PrayerGroupResponse(PrayerGroupBase): + id: int + leader_id: int + is_active: bool + created_at: datetime + leader_name: Optional[str] = None + member_count: int = 0 + is_member: bool = False + + class Config: + from_attributes = True + + +class PrayerGroupMemberResponse(BaseModel): + id: int + prayer_group_id: int + user_id: int + joined_at: datetime + user_name: Optional[str] = None + + class Config: + from_attributes = True + + +class PrayerStats(BaseModel): + total_requests: int + active_requests: int + answered_requests: int + prayers_offered: int + requests_created: int diff --git a/app/services/notification_service.py b/app/services/notification_service.py new file mode 100644 index 0000000..c11189d --- /dev/null +++ b/app/services/notification_service.py @@ -0,0 +1,164 @@ +from sqlalchemy.orm import Session +from typing import Optional, Dict, Any +import json +from app.models.notification import Notification, NotificationType, NotificationSettings +from app.models.user import User + + +class NotificationService: + @staticmethod + def create_notification( + db: Session, + recipient_id: int, + notification_type: NotificationType, + title: str, + message: str, + sender_id: Optional[int] = None, + data: Optional[Dict[Any, Any]] = None, + ) -> Notification: + """Create a new notification""" + + # Check if user wants this type of notification + settings = ( + db.query(NotificationSettings) + .filter(NotificationSettings.user_id == recipient_id) + .first() + ) + + if settings and not NotificationService._should_send_notification( + settings, notification_type + ): + return None + + notification = Notification( + recipient_id=recipient_id, + sender_id=sender_id, + type=notification_type, + title=title, + message=message, + data=json.dumps(data) if data else None, + ) + + db.add(notification) + db.commit() + db.refresh(notification) + return notification + + @staticmethod + def _should_send_notification( + settings: NotificationSettings, notification_type: NotificationType + ) -> bool: + """Check if user wants to receive this type of notification""" + type_mapping = { + NotificationType.CONNECTION_REQUEST: settings.connection_requests, + NotificationType.CONNECTION_ACCEPTED: settings.connection_requests, + NotificationType.POST_LIKE: settings.post_interactions, + NotificationType.POST_COMMENT: settings.post_interactions, + NotificationType.EVENT_INVITATION: settings.event_reminders, + NotificationType.EVENT_REMINDER: settings.event_reminders, + NotificationType.PRAYER_REQUEST: settings.prayer_requests, + NotificationType.MINISTRY_ASSIGNMENT: settings.ministry_updates, + NotificationType.DONATION_REMINDER: settings.donation_reminders, + NotificationType.BIRTHDAY_REMINDER: settings.birthday_reminders, + NotificationType.ANNOUNCEMENT: settings.announcements, + } + return type_mapping.get(notification_type, True) + + @staticmethod + def mark_as_read(db: Session, notification_id: int, user_id: int) -> bool: + """Mark notification as read""" + notification = ( + db.query(Notification) + .filter( + Notification.id == notification_id, Notification.recipient_id == user_id + ) + .first() + ) + + if notification: + notification.is_read = True + from datetime import datetime + + notification.read_at = datetime.utcnow() + db.commit() + return True + return False + + @staticmethod + def mark_all_as_read(db: Session, user_id: int) -> int: + """Mark all notifications as read for a user""" + from datetime import datetime + + count = ( + db.query(Notification) + .filter(Notification.recipient_id == user_id, Notification.is_read is False) + .update({"is_read": True, "read_at": datetime.utcnow()}) + ) + db.commit() + return count + + @staticmethod + def get_unread_count(db: Session, user_id: int) -> int: + """Get count of unread notifications""" + return ( + db.query(Notification) + .filter(Notification.recipient_id == user_id, Notification.is_read is False) + .count() + ) + + @staticmethod + def create_connection_request_notification( + db: Session, sender: User, recipient_id: int + ): + """Create notification for connection request""" + return NotificationService.create_notification( + db=db, + recipient_id=recipient_id, + sender_id=sender.id, + notification_type=NotificationType.CONNECTION_REQUEST, + title="New Connection Request", + message=f"{sender.first_name} {sender.last_name} wants to connect with you", + data={"sender_name": f"{sender.first_name} {sender.last_name}"}, + ) + + @staticmethod + def create_post_like_notification( + db: Session, liker: User, post_author_id: int, post_id: int + ): + """Create notification for post like""" + if liker.id == post_author_id: # Don't notify self + return None + + return NotificationService.create_notification( + db=db, + recipient_id=post_author_id, + sender_id=liker.id, + notification_type=NotificationType.POST_LIKE, + title="Post Liked", + message=f"{liker.first_name} {liker.last_name} liked your post", + data={ + "post_id": post_id, + "liker_name": f"{liker.first_name} {liker.last_name}", + }, + ) + + @staticmethod + def create_post_comment_notification( + db: Session, commenter: User, post_author_id: int, post_id: int + ): + """Create notification for post comment""" + if commenter.id == post_author_id: # Don't notify self + return None + + return NotificationService.create_notification( + db=db, + recipient_id=post_author_id, + sender_id=commenter.id, + notification_type=NotificationType.POST_COMMENT, + title="New Comment", + message=f"{commenter.first_name} {commenter.last_name} commented on your post", + data={ + "post_id": post_id, + "commenter_name": f"{commenter.first_name} {commenter.last_name}", + }, + ) diff --git a/main.py b/main.py index c8ff766..02029f8 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,19 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api import auth, users, posts, events, connections +from app.api import ( + auth, + users, + posts, + events, + connections, + uploads, + notifications, + ministries, + donations, + messages, + prayers, + attendance, +) import os app = FastAPI( @@ -24,6 +37,15 @@ app.include_router(users.router, prefix="/users", tags=["Users"]) app.include_router(posts.router, prefix="/posts", tags=["Posts"]) app.include_router(events.router, prefix="/events", tags=["Events"]) app.include_router(connections.router, prefix="/connections", tags=["Connections"]) +app.include_router(uploads.router, prefix="/uploads", tags=["File Uploads"]) +app.include_router( + notifications.router, prefix="/notifications", tags=["Notifications"] +) +app.include_router(ministries.router, prefix="/ministries", tags=["Ministries"]) +app.include_router(donations.router, prefix="/donations", tags=["Donations"]) +app.include_router(messages.router, prefix="/messages", tags=["Messaging"]) +app.include_router(prayers.router, prefix="/prayers", tags=["Prayer Requests"]) +app.include_router(attendance.router, prefix="/attendance", tags=["Attendance"]) @app.get("/") diff --git a/requirements.txt b/requirements.txt index be0014e..b60fa30 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,11 @@ passlib[bcrypt]==1.7.4 python-decouple==3.8 ruff==0.1.6 pydantic==2.5.0 -email-validator==2.1.0 \ No newline at end of file +email-validator==2.1.0 +pillow==10.1.0 +aiofiles==23.2.1 +python-dateutil==2.8.2 +celery==5.3.4 +redis==5.0.1 +emails==0.6.0 +jinja2==3.1.2 \ No newline at end of file