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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ docker compose down
docker rmi opengecko-backend opengecko-frontend 2>/dev/null || true

# 删除持久化数据库(⚠️ 数据不可恢复,谨慎操作)
rm -f backend/opengecko.db
rm -f data/opengecko.db

# 重新构建并启动
docker compose up -d --build
Expand All @@ -136,11 +136,11 @@ docker compose up -d --build
```bash
docker compose down && \
docker rmi opengecko-backend opengecko-frontend 2>/dev/null; \
rm -f backend/opengecko.db && \
rm -f data/opengecko.db && \
docker compose up -d --build
```

> **注意**:`backend/opengecko.db` 是通过 `docker-compose.override.yml` 挂载到宿主机的 SQLite 数据库文件,删除后所有数据(社区、内容、用户等)将清空,重启后重新初始化。生产环境使用 PostgreSQL 时此步骤不适用。
> **注意**:`data/opengecko.db` 是通过 `docker-compose.yml` 中 `./data:/app/data` 卷挂载到宿主机的 SQLite 数据库文件,删除后所有数据(社区、内容、用户等)将清空,重启后重新初始化。生产环境使用 PostgreSQL 时此步骤不适用。

### 首次使用流程

Expand Down
3 changes: 2 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
# 数据库
# ─────────────────────────────────────────────────────────────────────
# 开发默认 SQLite;生产建议换为 PostgreSQL(见 .env.prod.example)
DATABASE_URL=sqlite:///./opengecko.db
# 路径对应 docker-compose.yml 中 ./data:/app/data 卷,确保数据持久化
DATABASE_URL=sqlite:///./data/opengecko.db

# 连接池(PostgreSQL / MySQL 时生效)
# DB_POOL_SIZE=5
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""add event_communities many_to_many

Revision ID: 7e70abbef6ae
Revises: 003_data_migrations
Create Date: 2026-02-24 20:21:52.592608

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

revision: str = '7e70abbef6ae'
down_revision: Union[str, None] = '003_data_migrations'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
'event_communities',
sa.Column('event_id', sa.Integer(), nullable=False),
sa.Column('community_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['community_id'], ['communities.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['event_id'], ['events.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('event_id', 'community_id'),
)


def downgrade() -> None:
op.drop_table('event_communities')
16 changes: 11 additions & 5 deletions backend/app/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,9 +483,15 @@ def _send_password_reset_email(to_email: str, token: str) -> None:

msg.attach(MIMEText(html_body, "html", "utf-8"))

with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
if settings.SMTP_USE_TLS:
server.starttls()
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.sendmail(msg["From"], [to_email], msg.as_string())
# Port 465: direct SSL/TLS; port 587/others: STARTTLS
if settings.SMTP_PORT == 465:
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30) as server:
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.sendmail(msg["From"], [to_email], msg.as_string())
else:
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30) as server:
if settings.SMTP_USE_TLS:
server.starttls()
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.sendmail(msg["From"], [to_email], msg.as_string())

6 changes: 3 additions & 3 deletions backend/app/api/community_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,11 +253,11 @@ def get_community_dashboard(
calendar_events: list[CalendarEvent] = []

# 会议事件:按状态区分颜色
# scheduled → 蓝色 #0095ff;completed → 灰色 #94a3b8;cancelled → 浅红 #f87171
# scheduled → 蓝色 #0095ff;completed → 绿色 #10b981;cancelled → 灰色 #94a3b8
MEETING_COLORS = {
"scheduled": "#0095ff",
"completed": "#94a3b8",
"cancelled": "#f87171",
"completed": "#10b981",
"cancelled": "#94a3b8",
}
meeting_events = (
db.query(Meeting)
Expand Down
22 changes: 20 additions & 2 deletions backend/app/api/contents.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import insert, or_
from sqlalchemy import select as sa_select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload

from app.core.dependencies import check_content_edit_permission, get_current_user
from app.database import get_db
Expand All @@ -12,6 +12,7 @@
from app.schemas.content import (
ContentCalendarOut,
ContentCreate,
ContentListOut,
ContentOut,
ContentScheduleUpdate,
ContentStatusUpdate,
Expand Down Expand Up @@ -74,6 +75,7 @@ def list_contents(
source_type: str | None = None,
keyword: str | None = None,
community_id: int | None = Query(None),
unscheduled: bool = Query(False, description="若为 true,只返回未设置 scheduled_publish_at 的内容"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
Expand All @@ -87,8 +89,24 @@ def list_contents(
query = query.filter(Content.source_type == source_type)
if keyword:
query = query.filter(Content.title.contains(keyword))
if unscheduled:
query = query.filter(Content.scheduled_publish_at.is_(None))
total = query.count()
items = query.order_by(Content.updated_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
items_raw = (
query
.options(joinedload(Content.assignees))
.order_by(Content.updated_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
items = [
ContentListOut(
**{c.name: getattr(item, c.name) for c in item.__table__.columns},
assignee_names=[u.full_name or u.username for u in item.assignees],
)
for item in items_raw
]
return PaginatedContents(items=items, total=total, page=page, page_size=page_size)


Expand Down
19 changes: 17 additions & 2 deletions backend/app/api/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from app.core.dependencies import get_current_user
from app.database import get_db
from app.models import User
from app.models.community import Community
from app.models.event import (
ChecklistItem,
Event,
Expand Down Expand Up @@ -78,10 +79,18 @@ def create_event(
if data.event_type not in VALID_EVENT_TYPES:
raise HTTPException(400, f"event_type 必须为 {VALID_EVENT_TYPES}")

create_data = data.model_dump(exclude={"community_ids"})
event = Event(
owner_id=current_user.id,
**data.model_dump(),
**create_data,
)
# 处理多对多社区关联
all_ids = list(dict.fromkeys(data.community_ids + ([data.community_id] if data.community_id else [])))
if all_ids:
comms = db.query(Community).filter(Community.id.in_(all_ids)).all()
event.communities = comms
if not event.community_id and comms:
event.community_id = comms[0].id
db.add(event)
db.flush()

Expand Down Expand Up @@ -124,8 +133,14 @@ def update_event(
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(404, "活动不存在")
for key, value in data.model_dump(exclude_unset=True).items():
update_data = data.model_dump(exclude_unset=True, exclude={"community_ids"})
for key, value in update_data.items():
setattr(event, key, value)
# 处理 community_ids 更新
if data.community_ids is not None:
comms = db.query(Community).filter(Community.id.in_(data.community_ids)).all()
event.communities = comms
event.community_id = comms[0].id if comms else None
db.commit()
db.refresh(event)
return event
Expand Down
2 changes: 1 addition & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def cors_origins_list(self) -> list[str]:
RATE_LIMIT_DEFAULT: str = "120/minute"

# Database
DATABASE_URL: str = "sqlite:///./opengecko.db"
DATABASE_URL: str = "sqlite:///./data/opengecko.db"

# Database Connection Pool (for PostgreSQL/MySQL)
DB_POOL_SIZE: int = 5
Expand Down
9 changes: 9 additions & 0 deletions backend/app/database.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import os

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker

from app.config import settings

# Ensure SQLite parent directory exists (e.g., data/ in fresh CI checkout)
if settings.DATABASE_URL.startswith("sqlite:///"):
_db_path = settings.DATABASE_URL[len("sqlite:///"):]
_db_dir = os.path.dirname(_db_path)
if _db_dir:
os.makedirs(_db_dir, exist_ok=True)

# Database connection pool configuration
# For SQLite, we need check_same_thread=False
# For PostgreSQL/MySQL, use connection pooling settings
Expand Down
13 changes: 12 additions & 1 deletion backend/app/models/event.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
from datetime import datetime

from sqlalchemy import JSON, Boolean, Column, Date, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy import JSON, Boolean, Column, Date, DateTime, ForeignKey, Integer, String, Table, Text
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import relationship

from app.database import Base

# 活动 ↔ 社区 多对多关联表
event_communities_table = Table(
"event_communities",
Base.metadata,
Column("event_id", Integer, ForeignKey("events.id", ondelete="CASCADE"), primary_key=True),
Column("community_id", Integer, ForeignKey("communities.id", ondelete="CASCADE"), primary_key=True),
)


class EventTemplate(Base):
"""活动 SOP 模板"""
Expand Down Expand Up @@ -90,6 +98,9 @@ class Event(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

template = relationship("EventTemplate", back_populates="events")
communities = relationship(
"Community", secondary="event_communities", lazy="selectin"
)
checklist_items = relationship(
"ChecklistItem", back_populates="event", cascade="all, delete-orphan"
)
Expand Down
1 change: 1 addition & 0 deletions backend/app/schemas/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class ContentListOut(BaseModel):
scheduled_publish_at: datetime | None = None
created_at: datetime
updated_at: datetime
assignee_names: list[str] = []

model_config = {"from_attributes": True}

Expand Down
28 changes: 28 additions & 0 deletions backend/app/schemas/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class EventCreate(BaseModel):
title: str
event_type: str = "offline"
community_id: int | None = None
community_ids: list[int] = []
template_id: int | None = None
planned_at: datetime | None = None
duration_minutes: int | None = None
Expand All @@ -78,6 +79,7 @@ class EventCreate(BaseModel):
class EventUpdate(BaseModel):
title: str | None = None
event_type: str | None = None
community_ids: list[int] | None = None
planned_at: datetime | None = None
duration_minutes: int | None = None
location: str | None = None
Expand All @@ -97,9 +99,18 @@ class EventStatusUpdate(BaseModel):
status: str # draft / planning / ongoing / completed / cancelled


class CommunitySimple(BaseModel):
id: int
name: str

model_config = {"from_attributes": True}


class EventOut(BaseModel):
id: int
community_id: int | None
community_ids: list[int] = []
communities: list[CommunitySimple] = []
title: str
event_type: str
template_id: int | None
Expand All @@ -120,12 +131,22 @@ class EventOut(BaseModel):
created_at: datetime
updated_at: datetime

@classmethod
def model_validate(cls, obj, **kwargs):
instance = super().model_validate(obj, **kwargs)
# 从 communities 关系派生 community_ids
if hasattr(obj, 'communities'):
instance.community_ids = [c.id for c in obj.communities]
return instance

model_config = {"from_attributes": True}


class EventListOut(BaseModel):
id: int
community_id: int | None
community_ids: list[int] = []
communities: list[CommunitySimple] = []
title: str
event_type: str
status: str
Expand All @@ -134,6 +155,13 @@ class EventListOut(BaseModel):
owner_id: int | None
created_at: datetime

@classmethod
def model_validate(cls, obj, **kwargs):
instance = super().model_validate(obj, **kwargs)
if hasattr(obj, 'communities'):
instance.community_ids = [c.id for c in obj.communities]
return instance

model_config = {"from_attributes": True}


Expand Down
Empty file added backend/data/.gitkeep
Empty file.
Loading
Loading