diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..abc0873a --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +FLASK_APP=app.py +FLASK_ENV=development +SECRET_KEY=your_development_secret_key_here diff --git a/app.py b/app.py new file mode 100644 index 00000000..dcbde310 --- /dev/null +++ b/app.py @@ -0,0 +1,16 @@ +import os +from dotenv import load_dotenv +from app import create_app +from app.models.database import db + +# 載入環境變數 +load_dotenv() + +app = create_app() + +# 初始化建立資料表 +with app.app_context(): + db.create_all() + +if __name__ == '__main__': + app.run(debug=True) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 00000000..d125604e --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,48 @@ +from flask import Flask +import os +from .models.database import db + +# 載入路由 Blueprints +from .routes.main_routes import main_bp +from .routes.auth_routes import auth_bp +from .routes.event_routes import event_bp +from .routes.registration_routes import registration_bp + +def create_app(test_config=None): + # 建立與設定 app + app = Flask(__name__, instance_relative_config=True) + + # 確保 sqlite 在 Windows 的絕對路徑不會因 \ 造成 500 錯誤 + db_path = os.path.join(app.instance_path, 'application.db') + sqlite_uri = 'sqlite:///' + db_path.replace('\\', '/') + + # 預設設定 + app.config.from_mapping( + SECRET_KEY=os.environ.get('SECRET_KEY', 'dev'), + SQLALCHEMY_DATABASE_URI=sqlite_uri, + SQLALCHEMY_TRACK_MODIFICATIONS=False + ) + + if test_config is None: + # 如果有 instance/config.py,則載入 + app.config.from_pyfile('config.py', silent=True) + else: + # 使用測試設定 + app.config.from_mapping(test_config) + + # 確保 instance_path 存在 + try: + os.makedirs(app.instance_path) + except OSError: + pass + + # 初始化資料庫 + db.init_app(app) + + # 註冊 Blueprints + app.register_blueprint(main_bp) + app.register_blueprint(auth_bp) + app.register_blueprint(event_bp) + app.register_blueprint(registration_bp) + + return app diff --git a/app/__pycache__/__init__.cpython-314.pyc b/app/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 00000000..d3d471ba Binary files /dev/null and b/app/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/models/__pycache__/database.cpython-314.pyc b/app/models/__pycache__/database.cpython-314.pyc new file mode 100644 index 00000000..da838580 Binary files /dev/null and b/app/models/__pycache__/database.cpython-314.pyc differ diff --git a/app/models/database.py b/app/models/database.py new file mode 100644 index 00000000..eb349022 --- /dev/null +++ b/app/models/database.py @@ -0,0 +1,199 @@ +from flask_sqlalchemy import SQLAlchemy +from datetime import datetime + +db = SQLAlchemy() + +class User(db.Model): + """ + 使用者模型:代表學生或主辦方 + """ + __tablename__ = 'users' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + username = db.Column(db.String(100), nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + role = db.Column(db.String(20), nullable=False, default='student') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + events_organized = db.relationship('Event', backref='organizer', lazy=True) + registrations = db.relationship('Registration', backref='user', lazy=True) + + @classmethod + def create(cls, username, email, password_hash, role='student'): + """ + 新增一筆使用者記錄 + """ + try: + new_user = cls(username=username, email=email, password_hash=password_hash, role=role) + db.session.add(new_user) + db.session.commit() + return new_user + except Exception as e: + db.session.rollback() + print(f"Error creating user: {e}") + raise + + @classmethod + def get_by_id(cls, user_id): + """ + 取得單筆使用者記錄 + """ + try: + return cls.query.get(user_id) + except Exception as e: + print(f"Error getting user by id: {e}") + return None + + +class Event(db.Model): + """ + 活動模型:代表主辦方建立的活動 + """ + __tablename__ = 'events' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + organizer_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text) + event_date = db.Column(db.DateTime, nullable=False) + location = db.Column(db.String(200), nullable=False) + capacity = db.Column(db.Integer, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + registrations = db.relationship('Registration', backref='event', lazy=True) + + @classmethod + def create(cls, organizer_id, title, description, event_date, location, capacity): + """ + 新增一筆活動記錄 + """ + try: + new_event = cls( + organizer_id=organizer_id, + title=title, + description=description, + event_date=event_date, + location=location, + capacity=capacity + ) + db.session.add(new_event) + db.session.commit() + return new_event + except Exception as e: + db.session.rollback() + print(f"Error creating event: {e}") + raise + + @classmethod + def get_all(cls, keyword=None): + """ + 取得所有活動記錄,依建立時間遞減排序,支援關鍵字搜尋 + """ + try: + query = cls.query + if keyword: + search_term = f"%{keyword}%" + query = query.filter(db.or_(cls.title.ilike(search_term), cls.description.ilike(search_term))) + return query.order_by(cls.created_at.desc()).all() + except Exception as e: + print(f"Error getting all events: {e}") + return [] + + @classmethod + def get_by_id(cls, event_id): + """ + 取得單筆活動記錄 + """ + try: + return cls.query.get(event_id) + except Exception as e: + print(f"Error getting event by id: {e}") + return None + + def update(self, **kwargs): + """ + 更新活動記錄 + """ + try: + for key, value in kwargs.items(): + setattr(self, key, value) + db.session.commit() + except Exception as e: + db.session.rollback() + print(f"Error updating event: {e}") + raise + + def delete(self): + """ + 刪除活動記錄 + """ + try: + db.session.delete(self) + db.session.commit() + except Exception as e: + db.session.rollback() + print(f"Error deleting event: {e}") + raise + + +class Registration(db.Model): + """ + 報名記錄模型:追蹤學生報名狀態 + """ + __tablename__ = 'registrations' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + event_id = db.Column(db.Integer, db.ForeignKey('events.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + status = db.Column(db.String(20), nullable=False) # 'Confirmed', 'Waitlist', 'Cancelled' + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + @classmethod + def create(cls, event_id, user_id, status): + """ + 新增一筆報名記錄 + """ + try: + new_reg = cls(event_id=event_id, user_id=user_id, status=status) + db.session.add(new_reg) + db.session.commit() + return new_reg + except Exception as e: + db.session.rollback() + print(f"Error creating registration: {e}") + raise + + @classmethod + def get_by_id(cls, reg_id): + """ + 取得單筆報名記錄 + """ + try: + return cls.query.get(reg_id) + except Exception as e: + print(f"Error getting registration by id: {e}") + return None + + @classmethod + def get_user_registrations(cls, user_id): + """ + 取得指定使用者的所有報名記錄 + """ + try: + return cls.query.filter_by(user_id=user_id).order_by(cls.created_at.desc()).all() + except Exception as e: + print(f"Error getting user registrations: {e}") + return [] + + def update_status(self, new_status): + """ + 更新報名記錄狀態 + """ + try: + self.status = new_status + db.session.commit() + except Exception as e: + db.session.rollback() + print(f"Error updating registration status: {e}") + raise diff --git a/app/routes/__pycache__/auth_routes.cpython-314.pyc b/app/routes/__pycache__/auth_routes.cpython-314.pyc new file mode 100644 index 00000000..e8953940 Binary files /dev/null and b/app/routes/__pycache__/auth_routes.cpython-314.pyc differ diff --git a/app/routes/__pycache__/event_routes.cpython-314.pyc b/app/routes/__pycache__/event_routes.cpython-314.pyc new file mode 100644 index 00000000..a6b6a35a Binary files /dev/null and b/app/routes/__pycache__/event_routes.cpython-314.pyc differ diff --git a/app/routes/__pycache__/main_routes.cpython-314.pyc b/app/routes/__pycache__/main_routes.cpython-314.pyc new file mode 100644 index 00000000..10a6a7ea Binary files /dev/null and b/app/routes/__pycache__/main_routes.cpython-314.pyc differ diff --git a/app/routes/__pycache__/registration_routes.cpython-314.pyc b/app/routes/__pycache__/registration_routes.cpython-314.pyc new file mode 100644 index 00000000..4abf889b Binary files /dev/null and b/app/routes/__pycache__/registration_routes.cpython-314.pyc differ diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py new file mode 100644 index 00000000..63439827 --- /dev/null +++ b/app/routes/auth_routes.py @@ -0,0 +1,86 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, session +from werkzeug.security import generate_password_hash, check_password_hash +from app.models.database import User + +auth_bp = Blueprint('auth', __name__, url_prefix='/auth') + +@auth_bp.route('/register', methods=['GET', 'POST']) +def register(): + """ + 使用者註冊 + 輸入:[GET] 無 / [POST] 表單 (username, email, password) + """ + # 若已登入,導回首頁 + if 'user_id' in session: + return redirect(url_for('main.index')) + + if request.method == 'POST': + username = request.form.get('username') + email = request.form.get('email') + password = request.form.get('password') + + # 基礎輸入驗證 + if not username or not email or not password: + flash('所有欄位皆為必填。', 'danger') + return render_template('auth/register.html') + + # 檢查信箱是否已存在 + existing_user = User.query.filter_by(email=email).first() + if existing_user: + flash('此信箱已被註冊。', 'danger') + return render_template('auth/register.html') + + password_hash = generate_password_hash(password) + try: + # 建立使用者記錄 (預設角色設為 student) + User.create(username=username, email=email, password_hash=password_hash, role='student') + flash('註冊成功!請進行登入。', 'success') + return redirect(url_for('auth.login')) + except Exception as e: + flash('發生未知的錯誤,請稍後再試。', 'danger') + + # GET 請求,渲染註冊表單 + return render_template('auth/register.html') + + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + """ + 使用者登入 + 輸入:[GET] 無 / [POST] 表單 (email, password) + """ + # 若已登入,導回首頁 + if 'user_id' in session: + return redirect(url_for('main.index')) + + if request.method == 'POST': + email = request.form.get('email') + password = request.form.get('password') + + if not email or not password: + flash('請輸入信箱與密碼。', 'warning') + return render_template('auth/login.html') + + # 查詢使用者並比對密碼 + user = User.query.filter_by(email=email).first() + if user and check_password_hash(user.password_hash, password): + session.clear() + session['user_id'] = user.id + session['user_role'] = user.role + flash('登入成功!', 'success') + return redirect(url_for('main.index')) + else: + flash('信箱或密碼錯誤。', 'danger') + + # GET 請求,渲染登入表單 + return render_template('auth/login.html') + + +@auth_bp.route('/logout', methods=['POST']) +def logout(): + """ + 使用者登出 + """ + session.clear() + flash('您已成功登出。', 'info') + return redirect(url_for('main.index')) diff --git a/app/routes/event_routes.py b/app/routes/event_routes.py new file mode 100644 index 00000000..9ee62e60 --- /dev/null +++ b/app/routes/event_routes.py @@ -0,0 +1,61 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, session +from app.models.database import Event, Registration +from datetime import datetime + +event_bp = Blueprint('event', __name__, url_prefix='/events') + +@event_bp.route('/create', methods=['GET', 'POST']) +def create_event(): + # 權限檢查:只有主辦方可以建立活動 + if not session.get('user_id') or session.get('user_role') != 'organizer': + flash('只有主辦方可以建立活動。', 'danger') + return redirect(url_for('main.index')) + + if request.method == 'POST': + title = request.form.get('title') + description = request.form.get('description') + event_date_str = request.form.get('event_date') + location = request.form.get('location') + capacity_str = request.form.get('capacity') + + if not all([title, description, event_date_str, location, capacity_str]): + flash('所有欄位皆為必填。', 'danger') + return render_template('events/create.html') + + try: + event_date = datetime.fromisoformat(event_date_str) + capacity = int(capacity_str) + new_event = Event.create( + organizer_id=session.get('user_id'), + title=title, + description=description, + event_date=event_date, + location=location, + capacity=capacity + ) + flash('活動建立成功!', 'success') + return redirect(url_for('event.event_detail', event_id=new_event.id)) + except Exception as e: + flash(f'建立失敗:{e}', 'danger') + + return render_template('events/create.html') + +@event_bp.route('/', methods=['GET']) +def event_detail(event_id): + event = Event.get_by_id(event_id) + if not event: + flash('找不到該活動。', 'warning') + return redirect(url_for('main.index')) + + # 計算目前的正取人數 + confirmed_count = sum(1 for r in event.registrations if r.status == 'Confirmed') + + # 檢查登入者的報名狀態 + user_reg = None + if session.get('user_id'): + for r in event.registrations: + if r.user_id == session.get('user_id') and r.status != 'Cancelled': + user_reg = r + break + + return render_template('events/detail.html', event=event, confirmed_count=confirmed_count, user_reg=user_reg) diff --git a/app/routes/main_routes.py b/app/routes/main_routes.py new file mode 100644 index 00000000..8cfba4ba --- /dev/null +++ b/app/routes/main_routes.py @@ -0,0 +1,16 @@ +from flask import Blueprint, render_template, request +from app.models.database import Event + +main_bp = Blueprint('main', __name__) + +@main_bp.route('/', methods=['GET']) +def index(): + """ + 首頁 / 活動列表 + 輸入:無 (可接受 keyword 參數) + 處理邏輯:呼叫 Event.get_all(keyword) 取出所有活動,按時間排序 + 輸出:渲染 templates/index.html + """ + keyword = request.args.get('keyword', '').strip() + events = Event.get_all(keyword=keyword) + return render_template('index.html', events=events, keyword=keyword) diff --git a/app/routes/registration_routes.py b/app/routes/registration_routes.py new file mode 100644 index 00000000..428fc778 --- /dev/null +++ b/app/routes/registration_routes.py @@ -0,0 +1,77 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, session +from app.models.database import Event, Registration + +registration_bp = Blueprint('registration', __name__) + +@registration_bp.route('/events//register', methods=['POST']) +def register_event(event_id): + if not session.get('user_id'): + flash('請先登入才能報名。', 'warning') + return redirect(url_for('auth.login')) + + user_id = session.get('user_id') + event = Event.get_by_id(event_id) + if not event: + flash('找不到該活動。', 'danger') + return redirect(url_for('main.index')) + + # 檢查是否已報名 + existing_reg = Registration.query.filter_by(event_id=event_id, user_id=user_id).filter(Registration.status != 'Cancelled').first() + if existing_reg: + flash('您已經報名或候補此活動了。', 'info') + return redirect(url_for('event.event_detail', event_id=event_id)) + + # 計算目前的正取人數 + confirmed_count = Registration.query.filter_by(event_id=event_id, status='Confirmed').count() + + if confirmed_count < event.capacity: + status = 'Confirmed' + msg = '報名成功!您已正取。' + else: + status = 'Waitlist' + msg = '名額已滿,您已自動轉入候補名單。' + + try: + Registration.create(event_id=event_id, user_id=user_id, status=status) + flash(msg, 'success') + except Exception as e: + flash(f'報名失敗:{e}', 'danger') + + return redirect(url_for('registration.my_registrations')) + +@registration_bp.route('/events//cancel', methods=['POST']) +def cancel_registration(event_id): + if not session.get('user_id'): + return redirect(url_for('auth.login')) + + user_id = session.get('user_id') + reg = Registration.query.filter_by(event_id=event_id, user_id=user_id).filter(Registration.status != 'Cancelled').first() + + if not reg: + flash('找不到您的報名紀錄。', 'danger') + return redirect(url_for('event.event_detail', event_id=event_id)) + + try: + was_confirmed = (reg.status == 'Confirmed') + reg.update_status('Cancelled') + flash('已取消報名。', 'success') + + # 如果取消的是正取,且有候補名單,則自動遞補第一順位 + if was_confirmed: + next_waitlist = Registration.query.filter_by(event_id=event_id, status='Waitlist').order_by(Registration.created_at.asc()).first() + if next_waitlist: + next_waitlist.update_status('Confirmed') + + except Exception as e: + flash(f'取消失敗:{e}', 'danger') + + return redirect(url_for('event.event_detail', event_id=event_id)) + +@registration_bp.route('/my_registrations', methods=['GET']) +def my_registrations(): + if not session.get('user_id'): + return redirect(url_for('auth.login')) + + user_id = session.get('user_id') + regs = Registration.get_user_registrations(user_id) + return render_template('registrations/my_list.html', registrations=regs) diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 00000000..1c387ef5 --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,167 @@ +:root { + --primary: #8a2be2; + --primary-light: #b05bff; + --background: #0f111a; + --surface: rgba(25, 27, 38, 0.6); + --surface-border: rgba(255, 255, 255, 0.1); + --text-main: #f0f0f5; + --text-muted: #9aa0a6; +} + +body.dark-theme { + background-color: var(--background); + background-image: + radial-gradient(circle at top right, rgba(138, 43, 226, 0.15), transparent 400px), + radial-gradient(circle at bottom left, rgba(43, 138, 226, 0.1), transparent 400px); + color: var(--text-main); + font-family: 'Outfit', sans-serif; + min-height: 100vh; +} + +/* Glassmorphism Navigation */ +.glass-nav { + background: rgba(15, 17, 26, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--surface-border); + transition: all 0.3s ease; +} + +.logo-text { + font-weight: 700; + font-size: 1.5rem; + background: linear-gradient(90deg, #b05bff, #64b5f6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.nav-link { + color: var(--text-muted) !important; + font-weight: 400; + transition: color 0.2s ease; +} + +.nav-link:hover { + color: var(--text-main) !important; +} + +/* Glassmorphism Cards & Forms */ +.glass-panel { + background: var(--surface); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--surface-border); + border-radius: 16px; + padding: 2.5rem; + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3); +} + +.form-label { + font-weight: 600; + color: var(--text-muted); +} + +.form-control { + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--surface-border); + color: var(--text-main); + border-radius: 8px; + padding: 0.8rem 1rem; +} + +.form-control:focus { + background: rgba(255, 255, 255, 0.1); + color: var(--text-main); + border-color: var(--primary-light); + box-shadow: 0 0 0 0.25rem rgba(176, 91, 255, 0.25); +} + +/* Auth Cards Size */ +.auth-wrapper { + max-width: 450px; + margin: 3rem auto; +} + +/* Buttons */ +.btn-primary { + background: linear-gradient(135deg, var(--primary), #5e35b1); + border: none; + font-weight: 600; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(138, 43, 226, 0.4); + background: linear-gradient(135deg, #9b4eff, #6841be); +} + +.glass-btn { + backdrop-filter: blur(4px); + border: 1px solid rgba(255,255,255,0.2); +} + +/* Flash Messages */ +.flash-container { + max-width: 600px; + margin: 0 auto 2rem auto; +} +.glass-alert { + background: rgba(30, 30, 40, 0.8); + backdrop-filter: blur(10px); + border: 1px solid var(--surface-border); + color: var(--text-main); +} +.alert-success { border-left: 4px solid #00c853; } +.alert-danger { border-left: 4px solid #ff3d00; } +.alert-warning { border-left: 4px solid #ffea00; } +.alert-info { border-left: 4px solid #00b0ff; } + +/* Micro Animations */ +.fade-in-up { + animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Event List Card */ +.event-card { + background: var(--surface); + border: 1px solid var(--surface-border); + border-radius: 12px; + overflow: hidden; + backdrop-filter: blur(10px); + transition: transform 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; + height: 100%; +} +.event-card:hover { + transform: translateY(-5px); + border-color: var(--primary-light); + box-shadow: 0 10px 20px rgba(0,0,0,0.3); +} +.event-card-body { + padding: 1.5rem; +} +.event-date { + color: var(--primary-light); + font-size: 0.9rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.5rem; +} +.event-title { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 1rem; +} +.event-meta { + color: var(--text-muted); + font-size: 0.9rem; + display: flex; + align-items: center; + gap: 0.5rem; +} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 00000000..e9362faf --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

歡迎回來

+

登入 NexEvent 帳號以繼續操作

+ +
+
+ + +
+ +
+ + +
+ + +
+ +
+ 還沒有帳號嗎? 立即註冊 +
+
+
+{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 00000000..901a7eb2 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

建立帳號

+

加入 NexEvent 開始探索校園活動

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ 已經有帳號了嗎? 馬上登入 +
+
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 00000000..4bde4e3a --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,72 @@ + + + + + + + NexEvent | 探索精彩校園活動 + + + + + + + + + + +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + + +
+ {% block content %}{% endblock %} +
+
+ + + + diff --git a/app/templates/events/create.html b/app/templates/events/create.html new file mode 100644 index 00000000..5ed12b28 --- /dev/null +++ b/app/templates/events/create.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

建立新活動

+

請填寫詳細說明,吸引大家報名

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+{% endblock %} diff --git a/app/templates/events/detail.html b/app/templates/events/detail.html new file mode 100644 index 00000000..653f70d1 --- /dev/null +++ b/app/templates/events/detail.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

{{ event.title }}

+
+ {{ event.event_date.strftime('%Y-%m-%d %H:%M') }} + {{ event.location }} +
+
{{ event.description }}
+
+
+ +
+
+

報名狀態

+ +
+ 開放總額 + {{ event.capacity }} 人 +
+
+ 已正取 + {{ confirmed_count }} 人 +
+ + {% if user_reg %} +
+
+ {% if user_reg.status == 'Confirmed' %} + 🎉 報名成功 (正取) + {% else %} + ⏳ 候補中 + {% endif %} +
+ 您已登記參加此活動。 +
+
+ +
+ {% else %} + {% if session.get('user_id') %} +
+ +
+ {% else %} + 登入後報名 + {% endif %} + {% endif %} +
+
+
+{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 00000000..2fd4081f --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block content %} +
+

探索精彩校園活動

+

不管是講座、社團招新還是各類比賽,在這裡一網打盡!

+
+ + +
+
+
+ + +
+
+
+ +
+ {% if events %} + {% for event in events %} +
+
+
+
+ {{ event.event_date.strftime('%Y-%m-%d %H:%M') }} +
+
{{ event.title }}
+

+ {{ event.description }} +

+
+
+ 📍 {{ event.location }} + + 👥 {{ event.capacity }} 人 +
+ +
+
+
+
+ {% endfor %} + {% else %} +
+
+

目前還沒有任何活動開放報名 😢

+
+
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/registrations/my_list.html b/app/templates/registrations/my_list.html new file mode 100644 index 00000000..c39dfca1 --- /dev/null +++ b/app/templates/registrations/my_list.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} + +{% block content %} +
+

我的報名紀錄

+

隨時追蹤您的各項活動報名狀態與候補進度

+
+ +
+ {% if registrations %} +
+ + + + + + + + + + + + {% for reg in registrations %} + + + + + + + + {% endfor %} + +
活動名稱舉辦時間地點報名狀態操作
+ + {{ reg.event.title }} + + {{ reg.event.event_date.strftime('%Y-%m-%d %H:%M') }}{{ reg.event.location }} + {% if reg.status == 'Confirmed' %} + ✅ 已正取 + {% elif reg.status == 'Waitlist' %} + ⏳ 候補中 + {% else %} + ❌ 已取消 + {% endif %} + + {% if reg.status != 'Cancelled' %} +
+ +
+ {% endif %} +
+
+ {% else %} +
+

您還沒有報名任何活動 📝

+ 去探索活動 +
+ {% endif %} +
+{% endblock %} diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 00000000..0ff3ce0c --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,32 @@ +-- SQLite 語法:建立活動報名系統資料表 + +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(100) NOT NULL, + email VARCHAR(120) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'student', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organizer_id INTEGER NOT NULL, + title VARCHAR(200) NOT NULL, + description TEXT, + event_date DATETIME NOT NULL, + location VARCHAR(200) NOT NULL, + capacity INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (organizer_id) REFERENCES users(id) +); + +CREATE TABLE registrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + status VARCHAR(20) NOT NULL, -- 'Confirmed', 'Waitlist', 'Cancelled' + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (event_id) REFERENCES events(id), + FOREIGN KEY (user_id) REFERENCES users(id) +); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 00000000..e442cc7e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,99 @@ +# 系統架構文件 (Architecture):活動報名系統 + +## 1. 技術架構說明 + +本系統採用傳統的伺服器端渲染 (Server-Side Rendering, SSR) 架構,以減少初期開發成本,並能快速達成專案目標。以下為選用的技術與原因: + +* **後端框架:Python + Flask** + * **原因:** Flask 是一個輕量級的 Web 框架,適合快速開發 MVP。它的設計簡單直覺,擴充性強,非常適合本專案中小型活動報名系統的規模。 +* **模板引擎:Jinja2** + * **原因:** Jinja2 內建於 Flask 中,這讓我們可以輕鬆將後端的資料透過變數注入到前端 HTML 中,進行動態頁面渲染,不需再另外架設前端 API 與建立複雜的前後端分離架構。 +* **資料庫:SQLite** + * **原因:** SQLite 是輕量的檔案型資料庫,不需要安裝和設定獨立的資料庫伺服器,對中小型專題與初期測試來說效能已完全足夠。未來若有需要擴展規模,也可以再考慮遷移至其他進階的關聯式資料庫。 + +### Flask MVC 模式說明 + +我們採用類似 MVC (Model-View-Controller) 的概念來組織 Flask 專案結構: +* **Model (資料模型):** 負責一切與資料庫相關的邏輯。定義如何儲存、查詢與修改活動紀錄與報名名單。 +* **View (視圖):** 在這裡是指 HTML 模板 (Jinja2)。負責呈現資料與建構使用者介面。 +* **Controller (控制器):** 即 Flask 的「路由 (Routes)」。它負責接收瀏覽器的請求 (Request),向 Model 請求相關資料後,再呼叫 View(模板引擎)處理渲染,最後將結果回傳給瀏覽器。 + +--- + +## 2. 專案資料夾結構 + +本專案採用分類明確的資料夾結構,以確保各元件職責分離,提升後續維護的便利性。 + +```text +web_app_development/ +├── app/ # 主要應用程式的資料夾 +│ ├── __init__.py # 初始化 Flask 應用程式、載入設定 +│ ├── models/ # 資料庫模型 (Model) +│ │ └── database.py # 定義資料表與類別 (例如:活動、報名者) +│ ├── routes/ # 應用程式路由邏輯處理 (Controller) +│ │ ├── auth_routes.py # 登入註冊相關路由 +│ │ ├── event_routes.py # 活動相關功能路由 (新增活動、瀏覽活動) +│ │ └── registration_routes.py # 報名與結果查詢相關路由 +│ ├── templates/ # Jinja2 網頁 HTML 模板 (View) +│ │ ├── base.html # 全站共用的佈局模板 (如 Navbar, Footer) +│ │ ├── index.html # 首頁 / 活動列表頁 +│ │ ├── create_event.html # 新增活動表單頁 +│ │ ├── event_detail.html # 活動詳細資訊頁 +│ │ └── my_registrations.html # 我的報名紀錄查詢頁 +│ └── static/ # 靜態資源 (CSS / JS / Images) +│ ├── css/ +│ │ └── style.css # 全站共用樣式表 +│ ├── js/ +│ │ └── scripts.js # 前端互動邏輯腳本 +│ └── images/ # 圖片存放區 +├── instance/ # 存放敏感或執行時產生的資料 +│ └── application.db # SQLite 本地資料庫檔案 +├── docs/ # 開發文件存放區 +│ ├── PRD.md # 產品需求文件 +│ └── ARCHITECTURE.md # (本檔案) 系統架構文件 +├── app.py # 程式進入點 (負責啟動應用程式) +├── requirements.txt # Python 相依套件清單 +└── README.md # 專案介紹及使用說明 +``` + +--- + +## 3. 元件關係圖 + +以下展示使用者發起請求後,各系統元件之間的互動流程: + +```mermaid +flowchart TD + User([瀏覽器 Browser]) + + subgraph "Flask Application (後端)" + Router[Flask Route (Controller)] + Template[Jinja2 Template (View)] + Model[Database Model (Model)] + end + + DB[(SQLite 資料庫)] + + %% 請求流程 + User -- "1. 發送 HTTP Request\n(如:GET /event/1)" --> Router + Router -- "2. 查詢資料\n(取得活動與報名狀況)" --> Model + Model -- "3. 執行 SQL 語法" --> DB + DB -- "4. 回傳查詢結果" --> Model + Model -- "5. 將資料回傳" --> Router + Router -- "6. 傳遞資料與選擇模板" --> Template + Template -- "7. 渲染出 HTML 結果" --> Router + Router -- "8. 回傳 HTTP Response\n(顯示網頁)" --> User +``` + +--- + +## 4. 關鍵設計決策 + +1. **不採用前後端分離開發模式** + * **原因:** 此系統為早期 MVP 階段,功能相對聚焦。不採用前後端分離(如使用 React / Vue),可以省掉複雜的 API 串接與跨域處理問題。交由 Flask + Jinja2 包辦能將所有邏輯集中,大幅降低開發時程與團隊合作的溝通成本。 +2. **依功能拆分路由模組 (Blueprints)** + * **原因:** 將路由依照業務邏輯分門別類放在 `routes/` 資料夾內(例如:會員認證 `auth`、活動 `event`、報名 `registration`),而不是全部塞入單一 `app.py` 中。這樣可確保程式碼可讀性,未來加入新功能時也不容易發生修改衝突。 +3. **資料庫模型採狀態標記處理候補機制** + * **原因:** 為滿足「滿額後轉候補、取消時自動遞補」的需求,在報名紀錄中加入狀態欄位(Status:如 `正取`, `候補`, `取消`)。當有人取消時,系統只需用 SQL 語法撈取狀態為 `候補` 且時間最早的一筆紀錄,變更為 `正取` 即可,無需複雜的資料表搬演程序,確保一致性與效能。 +4. **共用版面設計 (Base Template)** + * **原因:** 在 Jinja2 中建立 `base.html` 來管理所有的通用配置(包含標題欄位 Navbar、頁腳 Footer 以及 CSS / JS 檔案載入)。首頁或內頁只需繼承這個基礎模板即可,未來若想修改外觀,只需更動一個檔案便能套用全站。 diff --git a/docs/DB_DESIGN.md b/docs/DB_DESIGN.md new file mode 100644 index 00000000..37d1e54d --- /dev/null +++ b/docs/DB_DESIGN.md @@ -0,0 +1,70 @@ +# 資料庫設計文件 (DB Design):活動報名系統 + +## 1. ER 圖 (實體關係圖) + +以下圖表展示本系統中各個資料表的結構及其關聯性: + +```mermaid +erDiagram + USERS ||--o{ EVENTS : "建立 (organizer)" + USERS ||--o{ REGISTRATIONS : "報名 (student)" + EVENTS ||--o{ REGISTRATIONS : "包含" + + USERS { + int id PK + string username + string email + string password_hash + string role "預設為 student,可為 organizer" + datetime created_at + } + + EVENTS { + int id PK + int organizer_id FK "對應 users.id" + string title + string description + datetime event_date + string location + int capacity "正取人數上限" + datetime created_at + } + + REGISTRATIONS { + int id PK + int event_id FK "對應 events.id" + int user_id FK "對應 users.id" + string status "包含 Confirmed, Waitlist, Cancelled" + datetime created_at "用於判斷候補先後順序" + } +``` + +## 2. 資料表詳細說明 + +### USERS (使用者表) +儲存所有學生與主辦方的帳號資訊,包含身份權限控制。 +* `id` (INTEGER): 主鍵,自動遞增。 +* `username` (VARCHAR): 必填,使用者的顯示名稱。 +* `email` (VARCHAR): 必填,登入用的帳號,具唯一性 (UNIQUE)。 +* `password_hash` (VARCHAR): 必填,加密儲存的密碼。 +* `role` (VARCHAR): 必填,預設為 `student`(學生)。有建立活動權限之帳號則設為 `organizer`(主辦方)。 +* `created_at` (DATETIME): 帳號建立時間。 + +### EVENTS (活動表) +儲存所有活動的基本資訊,每一筆記錄代表一個開放報名的活動。 +* `id` (INTEGER): 主鍵,自動遞增。 +* `organizer_id` (INTEGER): 外鍵 (Foreign Key),對應 `users.id`,指明此活動由哪位主辦方建立。 +* `title` (VARCHAR): 必填,活動標題。 +* `description` (TEXT): 活動的詳細說明,不限長度。 +* `event_date` (DATETIME): 必填,活動預期舉辦的時間。 +* `location` (VARCHAR): 必填,活動地點。 +* `capacity` (INTEGER): 必填,設定該活動的正取名額上限。 +* `created_at` (DATETIME): 活動建立時間。 + +### REGISTRATIONS (報名紀錄表) +追蹤每個使用者報名各個活動的紀錄,這是處理「自動候補機制」的核心模型。 +* `id` (INTEGER): 主鍵,自動遞增。 +* `event_id` (INTEGER): 外鍵 (Foreign Key),對應 `events.id`。 +* `user_id` (INTEGER): 外鍵 (Foreign Key),對應 `users.id`。 +* `status` (VARCHAR): 必填,紀錄當前報名狀態。可為 `Confirmed` (正取)、`Waitlist` (候補)、`Cancelled` (已取消)。 +* `created_at` (DATETIME): 報名送出時間,時間越早代表順位越靠前,用於自動遞補時判定候補順序。 diff --git a/docs/FLOWCHART.md b/docs/FLOWCHART.md new file mode 100644 index 00000000..9967664a --- /dev/null +++ b/docs/FLOWCHART.md @@ -0,0 +1,94 @@ +# 流程圖文件 (Flowchart):活動報名系統 + +## 1. 使用者流程圖 (User Flow) + +這個流程圖展示了「學生」與「主辦方」兩種角色在系統中的主要操作路徑。 + +```mermaid +flowchart LR + Start([使用者進入網站]) --> Auth{是否登入?} + + Auth -->|未登入| Login[前往登入/註冊頁面] + Login --> Start + + Auth -->|已登入| Home[首頁 - 活動清單] + + %% 主辦方路徑 + Home -->|主辦方| CreateBtn[點擊建立活動] + CreateBtn --> CreateForm[填寫活動資訊表單] + CreateForm --> SubmitEvent{送出} + SubmitEvent -->|成功| EventDetail[活動詳細資訊頁] + + %% 學生路徑 + Home -->|學生| Browse[瀏覽活動清單] + Browse --> ClickEvent[點擊進入特定活動] + ClickEvent --> EventDetail + + EventDetail --> CheckReg{是否已報名?} + CheckReg -->|是| CancelBtn[點擊取消報名] + CancelBtn --> UpdateStatus[系統取消資格並自動遞補候補] + UpdateStatus --> EventDetail + + CheckReg -->|否| RegBtn[點擊我要報名] + RegBtn --> CheckFull{名額是否額滿?} + + CheckFull -->|否| RegSuccess[報名成功 - 正取] + CheckFull -->|是| RegWait[報名登記 - 轉入候補] + + RegSuccess --> MyList[我的報名紀錄頁] + RegWait --> MyList + EventDetail -->|導覽列進入| MyList + + MyList --> ViewStatus[查看當前狀態:正取/候補/已取消] +``` + +## 2. 系統序列圖 (Sequence Diagram) + +這張圖展示了本系統最核心的機制:「**學生報名活動及自動判定候補**」的資料流向。 + +```mermaid +sequenceDiagram + actor User as 學生 + participant Browser as 瀏覽器 (Browser) + participant Route as Flask Route (Controller) + participant Model as DB 模型 (Model) + participant DB as SQLite 資料庫 + + User->>Browser: 點擊「我要報名」 + Browser->>Route: POST /event/1/register + + Route->>Model: 查詢該活動正取名額狀況 + Model->>DB: SELECT COUNT(*) 取得目前正取人數 + DB-->>Model: 回傳目前人數 + Model-->>Route: 回傳可否直接正取 (True / False) + + alt 尚有名額 + Route->>Model: 建立報名紀錄 (Status = 'Confirmed') + else 已滿額 + Route->>Model: 建立報名紀錄 (Status = 'Waitlist') + end + + Model->>DB: INSERT INTO Registrations + DB-->>Model: 寫入成功 + Model-->>Route: 報名手續完成 + + Route-->>Browser: HTTP 302 重導向 + Browser->>User: 顯示「我的報名紀錄」頁面 (可見狀態結果) +``` + +## 3. 功能清單對照表 + +本表列出系統主要功能對應的 URL 路徑與 HTTP 方法,作為後續實作路由 (Routes) 的參考: + +| 功能項目 | HTTP 方法 | URL 路徑 | 功能說明 | +| :--- | :--- | :--- | :--- | +| **首頁 / 活動列表** | GET | `/` | 顯示所有開放中的活動清單 | +| **使用者登入** | GET / POST | `/login` | 顯示登入表單與執行登入認證 | +| **使用者註冊** | GET / POST | `/register` | 顯示註冊表單與建立新帳號 | +| **建立活動表單** | GET | `/event/create` | 顯示新增活動的頁面 (需主辦方權限) | +| **送出建立活動** | POST | `/event/create` | 接收資料並將新活動寫入 SQLite | +| **活動詳細資訊** | GET | `/event/` | 顯示特定活動的詳細內容與報名人數 | +| **報名活動** | POST | `/event//register` | 執行報名邏輯,系統自動判定正取或候補 | +| **取消報名** | POST | `/event//cancel` | 取消報名,系統將自動依序遞補候補者 | +| **報名紀錄查詢** | GET | `/my_registrations` | 查詢登入者所有報名過的活動與目前狀態 | +| **登出** | POST | `/logout` | 清除 Session 並登出當前帳號 | diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 00000000..e6f0f22b --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,77 @@ +# 產品需求文件 (PRD):剩食與雜物互助系統 + +## 1. 專案概述 + +### 背景與動機 +在校園或社區中,經常會有吃不完的食物(如活動剩下的便當)或是不再使用但仍完好的雜物(如二手書、文具、生活用品)。這些資源如果直接丟棄會造成浪費。本系統旨在提供一個互助平台,讓成員能夠輕鬆分享這些多餘的資源,有需要的人也能免費或以極低成本取得,從而減少浪費、促進資源循環。 + +### 目標用戶 +- **分享者(捐贈者):** 擁有剩食或閒置雜物,希望轉贈給需要的人的學生或社區成員。 +- **索取者(需求者):** 有需要特定物品或想要索取剩食,以節省開銷的成員。 + +### 核心價值主張 +提供一個快速、透明的媒合平台,讓閒置資源找到新主人,實踐環保與社群互助的精神。 + +--- + +## 2. 功能需求 + +以下為系統預計提供的主要功能與對應的使用者故事: + +### 1. 物品與剩食上架 (建立分享) +- **描述:** 使用者可以上傳想要分享的食物或雜物資訊(包含名稱、類別、數量、面交地點與圖片說明)。 +- **使用者故事:** 作為分享者,我希望能夠快速發布剩食或雜物的資訊,以便其他人能看到並向我索取。 + +### 2. 物品資訊瀏覽 +- **描述:** 首頁或列表頁展示目前所有「可索取」的資源,並可區分「剩食」與「雜物」。 +- **使用者故事:** 作為索取者,我希望能夠瀏覽目前有哪些資源正在分享,以便找到我需要的物品。 + +### 3. 關鍵字查詢功能 (核心實作) +- **描述:** 提供搜尋列,讓使用者輸入關鍵字,快速尋找特定名稱或描述的物品。 +- **使用者故事:** 作為索取者,我希望能夠透過輸入關鍵字(如:便當、微積分、檯燈),快速篩選出我感興趣的物品,以便節省尋找的時間。 + +### 4. 資源索取與狀態管理 +- **描述:** 使用者可以點擊「我要索取」,系統會記錄並更新物品狀態(如:可索取 -> 已預訂 -> 已送出)。 +- **使用者故事:** 作為索取者,我希望能夠一鍵預訂想要的物品,以便確認我能拿到它。 +- **使用者故事:** 作為分享者,我希望系統能標示物品已被索取,以便不再收到重複的索取要求。 + +### 5. 使用者註冊與登入 +- **描述:** 基本的會員系統,確保發布與索取者皆為實名或已驗證的校園/社區成員。 +- **使用者故事:** 作為系統管理員,我希望使用者必須登入才能發布或索取物品,以便維持平台的安全與信任。 + +--- + +## 3. 非功能需求 + +### 技術限制 +- **系統架構:** 基於 Python 的 Flask 框架開發。 +- **前端技術:** 使用 HTML 搭配 Jinja2 模板引擎與原生 CSS / JavaScript 進行渲染。 +- **資料庫:** 使用 SQLite 進行資料儲存與管理。 + +### 效能與安全考量 +- **效能:** 查詢功能需能快速回應,即便資料量增加也能在合理時間內載入搜尋結果。 +- **安全:** + - 避免 SQL Injection(特別是在關鍵字搜尋與表單輸入的環節)。 + - 使用者需登入才能進行互動,防止惡意發文或洗版。 + +--- + +## 4. MVP 範圍 (Minimum Viable Product) + +| 功能分類 | 優先層級 | 功能項目 | +| :--- | :--- | :--- | +| **Must Have**
(核心必備功能) | 高 | 1. 使用者註冊與登入
2. 物品上架與列表瀏覽
3. 關鍵字查詢功能
4. 物品狀態更新 (索取功能) | +| **Should Have**
(重要但不影響基本運作) | 中 | 1. 物品類別篩選 (剩食 / 雜物)
2. 簡單的留言或聯絡機制 (約定面交) | +| **Nice to Have**
(未來擴充或優化功能) | 低 | 1. 圖片上傳功能
2. 索取成功 Email 通知
3. 使用者評價系統 | + +--- + +## 5. 專案成員與分工 + +| 角色 | 姓名 | 負責項目 | +| :--- | :--- | :--- | +| 專案經理 (PM) | | | +| 前端工程師 (Front-end) | | | +| 後端工程師 (Back-end) | | | +| 設計師 (UI/UX) | | | +| QA (測試) | | | diff --git a/docs/ROUTES.md b/docs/ROUTES.md new file mode 100644 index 00000000..22a7c8f4 --- /dev/null +++ b/docs/ROUTES.md @@ -0,0 +1,92 @@ +# 路由與頁面設計文件 (Routes):活動報名系統 + +## 1. 路由總覽表格 + +| 功能 | HTTP 方法 | URL 路徑 | 對應模板 | 說明 | +| :--- | :--- | :--- | :--- | :--- | +| 首頁 / 活動列表 | GET | `/` | `templates/index.html` | 顯示所有開放報名的活動 | +| 註冊頁面 | GET | `/auth/register` | `templates/auth/register.html` | 顯示註冊表單 | +| 執行註冊 | POST | `/auth/register` | — | 建立帳號並寫入 DB,成功後重導向至登入頁 | +| 登入頁面 | GET | `/auth/login` | `templates/auth/login.html` | 顯示登入表單 | +| 執行登入 | POST | `/auth/login` | — | 驗證帳號密碼,成功後設定 Session,重導向首頁 | +| 執行登出 | POST | `/auth/logout` | — | 清除 Session,重導向至首頁 | +| 建立活動頁面 | GET | `/events/create` | `templates/events/create.html` | 顯示新增活動表單 (需為 organizer) | +| 執行建立活動 | POST | `/events/create` | — | 接收資料建立活動,成功後重導向至活動詳細頁 | +| 活動詳細資訊 | GET | `/events/` | `templates/events/detail.html` | 顯示活動內容、報名進度與報名按鈕 | +| 執行報名 | POST | `/events//register`| — | 依據目前報名人數處理正取/候補邏輯 | +| 執行取消報名 | POST | `/events//cancel` | — | 取消報名資格並觸發自動遞補機制 | +| 我的報名紀錄 | GET | `/my_registrations` | `templates/registrations/my_list.html` | 查詢登入者的所有活動報名狀態 | + +--- + +## 2. 每個路由的詳細說明 + +### Auth 認證相關 (`/auth`) +* **`GET /auth/register`** + * **輸入:** 無。 + * **處理邏輯:** 單純渲染註冊表單。如果已經登入,則重導向回首頁。 + * **輸出:** `auth/register.html`。 +* **`POST /auth/register`** + * **輸入:** 表單 (`username`, `email`, `password`)。 + * **處理邏輯:** 驗證 email 是否已被註冊。若無,呼叫 `User.create()`,並將密碼加密後儲存。 + * **輸出:** 成功則重導向 `/auth/login` 並閃現 (flash) 成功訊息;失敗則重新渲染註冊表單並顯示錯誤。 +* **`POST /auth/login`** + * **輸入:** 表單 (`email`, `password`)。 + * **處理邏輯:** 驗證密碼是否正確,通過後將 `user_id` 存入 Flask Session。 + * **輸出:** 成功重導向 `/` (首頁);失敗重新渲染登入表單並顯示錯誤。 +* **`POST /auth/logout`** + * **處理邏輯:** 清除系統中的 Session 資料。重導向 `/` (首頁)。 + +### Main 首頁路徑 (`/`) +* **`GET /`** + * **輸入:** 無。 + * **處理邏輯:** 呼叫 `Event.get_all()` 取出以建立時間排序的活動列表。 + * **輸出:** `index.html`。 + +### Events 活動相關 (`/events`) +* **`GET /events/create`** + * **處理邏輯:** 確認目前 User 的 `role` 是否為 `organizer`,若不是則回傳 403 或重導回首頁。 + * **輸出:** `events/create.html`。 +* **`POST /events/create`** + * **輸入:** 表單 (`title`, `description`, `event_date`, `location`, `capacity`)。 + * **處理邏輯:** 確保輸入合法後,呼叫 `Event.create()`。 + * **輸出:** 成功則重導向 `/events/<新增的 event_id>`。 +* **`GET /events/`** + * **輸入:** URL 參數 `id`。 + * **處理邏輯:** 呼叫 `Event.get_by_id(id)`。計算關聯的 `Registration` 中狀態為 `Confirmed` 的筆數,判斷是否已滿額。若目前使用者已登入,且已報名該活動,需一併取得其報名狀態,以便前端隱藏報名按鈕或顯示取消按鈕。 + * **輸出:** `events/detail.html`。若找不到該 ID 則回傳 404。 + +### Registrations 報名相關 +* **`POST /events//register`** + * **輸入:** URL 參數 `id`,使用者必須登入。 + * **處理邏輯:** 取得該活動資料。如果正取人數 `< capacity`,呼叫 `Registration.create(status='Confirmed')`;若正取已滿,則呼叫 `Registration.create(status='Waitlist')`。 + * **輸出:** 重導向至 `/my_registrations`,並提示報名成功或進入候補。 +* **`POST /events//cancel`** + * **輸入:** URL 參數 `id`,使用者必須登入。 + * **處理邏輯:** 找到該使用者對應的 `Registration`,將狀態改為 `Cancelled`。接著資料庫檢查該活動是否還有 `Waitlist` 的報名者。有的話,挑出 `created_at` 最早的那一筆,將其狀態更新為 `Confirmed`(自動遞補)。 + * **輸出:** 重導向回活動頁 `/events/` 或 `/my_registrations`,並提示成功取消。 +* **`GET /my_registrations`** + * **輸入:** 使用者必須登入。 + * **處理邏輯:** 呼叫 `Registration.get_user_registrations(user_id)`,並透過關聯取得活動標題與時間等資訊供前端顯示。 + * **輸出:** `registrations/my_list.html`。 + +--- + +## 3. Jinja2 模板清單 + +所有的模板將繼承自一個共用的 `base.html`,以保持視覺風格與導覽列的統一。 + +**基礎與共用模板:** +* `templates/base.html` (包含 Navbar、Flash messages 區塊、Footer) +* `templates/index.html` (首頁/活動列表卡片) + +**認證相關:** +* `templates/auth/login.html` (登入頁) +* `templates/auth/register.html` (註冊頁) + +**活動相關:** +* `templates/events/create.html` (新增活動表單,主辦方專屬) +* `templates/events/detail.html` (活動詳情頁,包含參加/取消按鈕) + +**報名相關:** +* `templates/registrations/my_list.html` (個人報名紀錄清單) diff --git a/instance/application.db b/instance/application.db new file mode 100644 index 00000000..78870273 Binary files /dev/null and b/instance/application.db differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..375aa93f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask +Flask-SQLAlchemy +python-dotenv diff --git a/web_app_development b/web_app_development new file mode 160000 index 00000000..308abe6e --- /dev/null +++ b/web_app_development @@ -0,0 +1 @@ +Subproject commit 308abe6ef73bd15dab6416b2184c266cca082553