From 1a5f32dc4e7bd504e8fbfc8608831d0857fd374f Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Sat, 7 Feb 2026 17:25:47 +0000 Subject: [PATCH] feat: add database models and CRUD operations --- backend/__init__.py | 1 + backend/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 166 bytes backend/__pycache__/config.cpython-310.pyc | Bin 0 -> 259 bytes backend/__pycache__/database.cpython-310.pyc | Bin 0 -> 1008 bytes backend/__pycache__/models.cpython-310.pyc | Bin 0 -> 1574 bytes backend/config.py | 3 + backend/crud.py | 130 ++++++++++++++++++ backend/database.py | 22 +++ backend/models.py | 34 +++++ backend/schemas.py | 72 ++++++++++ backend/tests/__init__.py | 1 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 172 bytes ...test_database.cpython-310-pytest-9.0.2.pyc | Bin 0 -> 2308 bytes backend/tests/test_database.py | 23 ++++ 14 files changed, 286 insertions(+) create mode 100644 backend/__init__.py create mode 100644 backend/__pycache__/__init__.cpython-310.pyc create mode 100644 backend/__pycache__/config.cpython-310.pyc create mode 100644 backend/__pycache__/database.cpython-310.pyc create mode 100644 backend/__pycache__/models.cpython-310.pyc create mode 100644 backend/config.py create mode 100644 backend/crud.py create mode 100644 backend/database.py create mode 100644 backend/models.py create mode 100644 backend/schemas.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/__pycache__/__init__.cpython-310.pyc create mode 100644 backend/tests/__pycache__/test_database.cpython-310-pytest-9.0.2.pyc create mode 100644 backend/tests/test_database.py diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..2f5d08c --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +# backend package diff --git a/backend/__pycache__/__init__.cpython-310.pyc b/backend/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b93a3dbb42df0752751d9de9f43466e7ee7696a4 GIT binary patch literal 166 zcmd1j<>g`kf}GO!Od%ls7{oyaj6jY95Erumi4=xl22Do4l?+87VFdBZQ$Hg=H&s75 zBQq~uUoXEPH7_|Qv0T4AzbL!7ATc>rKe3>oD6=?KH!r^=Gp$lLIW?~&wMaiHF*!Rm kFGW8-J~J<~BtBlRpz;=nO>TZlX-=vg$ckbnAi=@_0L)t`ssI20 literal 0 HcmV?d00001 diff --git a/backend/__pycache__/config.cpython-310.pyc b/backend/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c04459c64750bdd590255a9ffadc32b329e67955 GIT binary patch literal 259 zcmd1j<>g`kg4ELX%s3$Z7{oyaEI^I}5En}Ti4=wu#vFzyhE#?Hj44bh%nO;Km{J&n z88lg50%aNeZt=J{hB!Jo2D`?G2KiK}6&L1YmZWMYX68d!R{Hw-dir_!C7EfJ@yV%q zC8fC&H<`bIJU literal 0 HcmV?d00001 diff --git a/backend/__pycache__/database.cpython-310.pyc b/backend/__pycache__/database.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ee78dc6cb5279868a7ee97b20faebbc5eacf36a1 GIT binary patch literal 1008 zcmah|&2AGh5VoEDOS++Hfk+%d;*dkLLgI)JDL^l?NzA zhQJA@8Odo#a~3jcbe7d}E3|Suv@5-qIk_9U)!xdyydKsm0Sa|)bLWJG4X|eMzjo4mo7aJ|C)KIt^S;TK7V>8pu?xP&M!rXqIF!6WF_tsBg^8{#*1jo|IMa5L$*M|s z&chi|%b4Z&jbJ2m2$E4MhCx@3L12}WU?SCSF^&@m;_+Ce1*{2KrrXoC1Vjlc7{tjg z2p;gbj17O?@l-oQC?h^-V%Wk*U`H*=s7G(H?hmzs2mj>hi*bLAa7@dJjbBW)4-G%8 ztnFAk_9?w&;6I$){+koEguy$GcIKU74*)wvdumTarbo23js<8hhcY(h=*DF~Wtn;X zL<&)Hbpb7mrrlJerq*jFc@KddtF#1K+v!lb#&y-ebpwX%is0(&3%QyXR#n2@y}1J}F$*XnBQIDeq?{PMv$gtm`8CicMd^sj#8IVARB$N?}WK3e2kVK{=l@l_t^L#Ot8Of|3 ziyfJh+;!eKEMVa;4hwmHHzK>>M=S24O8;B_Nn~uvNzNL@t5y9y-x&Xb zzweCy-TB)k{KrIbLGjIcUA5r*uc+oFZs7S9fO87B{tXOwh|3(}F_(7<9r@7J{BwJ+(Rmy#wW?OO-=ZEP3xKq6P1I|7Z09ETUAM_HpMk0Jg#cS-=ED!Cg|(xrspQAdm-qh z;A$W8d2rRCeVCB(E82^0=BXDDF;P?ZEjGbSmQ7b(Ri!PJ3Ag|C;F|1`me)nwv^|uO z>*ef)iHf48k{5+Zib6K57pNyiann<=9hnwIhZ`7N1yDK)i)_4#sSt&aL&onmEmsp8 zU{%?fpls@^YGr%@6u>ZDX%*pcTXqffgc^E; zy+=UQ7ihjjgD9vE(LA+(Pz#J*;D_pj~R9lXDw&@TlN55Omzf7g*rx#n{7EI-7;B0O-5nc9p7>a%~=%%PA2vH2FTIh;*;2_Gz#;VOh|Xvu%1V4|;v Wv-q_O(=;xaIJx_|%_8>>vcCa+LWYq5 literal 0 HcmV?d00001 diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..070fd56 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,3 @@ +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./notify_center.db") diff --git a/backend/crud.py b/backend/crud.py new file mode 100644 index 0000000..72184c5 --- /dev/null +++ b/backend/crud.py @@ -0,0 +1,130 @@ +from typing import List, Optional, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, or_ +from sqlalchemy.orm import selectinload +from backend.models import Channel, Notification + +async def get_channel(db: AsyncSession, channel_id: int) -> Optional[Channel]: + result = await db.execute(select(Channel).where(Channel.id == channel_id)) + return result.scalar_one_or_none() + +async def get_channel_by_name(db: AsyncSession, name: str) -> Optional[Channel]: + result = await db.execute(select(Channel).where(Channel.name == name)) + return result.scalar_one_or_none() + +async def get_channels(db: AsyncSession, skip: int = 0, limit: int = 100) -> List[Channel]: + result = await db.execute( + select(Channel).offset(skip).limit(limit) + ) + return result.scalars().all() + +async def get_channels_by_tags(db: AsyncSession, tags: List[str]) -> List[Channel]: + """获取包含任一标签的所有活动通道""" + conditions = [Channel.tags.contains([tag]) for tag in tags] + result = await db.execute( + select(Channel).where( + and_( + Channel.is_active == True, + or_(*conditions) + ) + ) + ) + return result.scalars().all() + +async def create_channel(db: AsyncSession, channel_data: Dict[str, Any]) -> Channel: + db_channel = Channel(**channel_data) + db.add(db_channel) + await db.commit() + await db.refresh(db_channel) + return db_channel + +async def update_channel( + db: AsyncSession, + channel_id: int, + channel_data: Dict[str, Any] +) -> Optional[Channel]: + db_channel = await get_channel(db, channel_id) + if not db_channel: + return None + + for key, value in channel_data.items(): + if value is not None: + setattr(db_channel, key, value) + + await db.commit() + await db.refresh(db_channel) + return db_channel + +async def delete_channel(db: AsyncSession, channel_id: int) -> bool: + db_channel = await get_channel(db, channel_id) + if not db_channel: + return False + + await db.delete(db_channel) + await db.commit() + return True + +# Notification CRUD +async def create_notification( + db: AsyncSession, + channel_id: int, + notification_data: Dict[str, Any] +) -> Notification: + db_notification = Notification(channel_id=channel_id, **notification_data) + db.add(db_notification) + await db.commit() + await db.refresh(db_notification) + return db_notification + +async def update_notification_status( + db: AsyncSession, + notification_id: int, + status: str, + error_msg: Optional[str] = None, + sent_at = None +) -> Optional[Notification]: + result = await db.execute( + select(Notification).where(Notification.id == notification_id) + ) + notification = result.scalar_one_or_none() + if notification: + notification.status = status + notification.error_msg = error_msg + notification.sent_at = sent_at + await db.commit() + await db.refresh(notification) + return notification + +async def get_notifications( + db: AsyncSession, + skip: int = 0, + limit: int = 100, + channel_id: Optional[int] = None, + status: Optional[str] = None +) -> List[Notification]: + query = select(Notification) + + if channel_id: + query = query.where(Notification.channel_id == channel_id) + if status: + query = query.where(Notification.status == status) + + query = query.order_by(Notification.created_at.desc()) + query = query.offset(skip).limit(limit) + + result = await db.execute(query) + return result.scalars().all() + +async def get_notification_stats(db: AsyncSession) -> Dict[str, int]: + from sqlalchemy import func + + result = await db.execute( + select(Notification.status, func.count(Notification.id)) + .group_by(Notification.status) + ) + stats = {status: count for status, count in result.fetchall()} + + total = await db.execute(select(func.count(Notification.id))) + stats['total'] = total.scalar() + + return stats diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..d7e1193 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,22 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker, declarative_base +from sqlalchemy import text +from backend.config import DATABASE_URL + +engine = create_async_engine(DATABASE_URL, echo=False) +Base = declarative_base() + +AsyncSessionLocal = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False +) + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..e651081 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,34 @@ +from datetime import datetime +from typing import Optional, List +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, JSON +from sqlalchemy.orm import relationship +from backend.database import Base + +class Channel(Base): + __tablename__ = "channels" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, unique=True, nullable=False, index=True) + type = Column(String, nullable=False) + config = Column(JSON, default=dict) + tags = Column(JSON, default=list) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + notifications = relationship("Notification", back_populates="channel") + +class Notification(Base): + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True, index=True) + channel_id = Column(Integer, ForeignKey("channels.id")) + title = Column(String, nullable=True) + body = Column(Text, nullable=False) + priority = Column(String, default="normal") + status = Column(String, default="pending") + error_msg = Column(Text, nullable=True) + sent_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + channel = relationship("Channel", back_populates="notifications") diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..0ceecc0 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,72 @@ +from datetime import datetime +from typing import Optional, List, Dict, Any +from pydantic import BaseModel, ConfigDict + +# Channel Schemas +class ChannelBase(BaseModel): + name: str + type: str + config: Dict[str, Any] = {} + tags: List[str] = [] + is_active: bool = True + +class ChannelCreate(ChannelBase): + pass + +class ChannelUpdate(BaseModel): + name: Optional[str] = None + type: Optional[str] = None + config: Optional[Dict[str, Any]] = None + tags: Optional[List[str]] = None + is_active: Optional[bool] = None + +class Channel(ChannelBase): + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + updated_at: datetime + +class ChannelList(BaseModel): + channels: List[Channel] + total: int + +# Notification Schemas +class NotificationBase(BaseModel): + title: Optional[str] = None + body: str + priority: str = "normal" + +class NotificationCreate(NotificationBase): + channel_id: int + +class Notification(NotificationBase): + model_config = ConfigDict(from_attributes=True) + + id: int + channel_id: int + status: str + error_msg: Optional[str] = None + sent_at: Optional[datetime] = None + created_at: datetime + +class NotificationResult(BaseModel): + channel: str + channel_id: int + status: str + notification_id: int + error_msg: Optional[str] = None + +class NotifyRequest(BaseModel): + channels: Optional[List[str]] = None + tags: Optional[List[str]] = None + title: Optional[str] = None + body: str + priority: str = "normal" + +class NotifyResponse(BaseModel): + success: bool + results: List[NotificationResult] + total: int + sent: int + failed: int diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..65140f2 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +# tests package diff --git a/backend/tests/__pycache__/__init__.cpython-310.pyc b/backend/tests/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1740b916966d333b82342c0e02f1fa946345b879 GIT binary patch literal 172 zcmd1j<>g`kg6`7xOaUPM7{oyaj6jY95Erumi4=xl22Do4l?+87VFdBZUq2&1H&s75 zBQq~uUoXEPH7_|Qv0T4AzbL!7ATc>rKe3>oD6=?KH!r^=Gp$lLIW?~&wMaiHF*!Rm qFGas3wYa2MKR!M)FS8^*Uaz3?7Kcr4eoARhsvXFpVkRKL!TrK6C;Ge+L9X1pBCl)vN`&hA;|@S_Y>pX3bP9R?UK0 z^Rq#&mQ!oGpAQCV14=jiLQt#~gTdNhu&cH!7^)2^f5smUO0^P3)V#R+qD4$%T}2zX zHqy1TB&X~>1ka;N{yyY`&Q`E6V!9T?TsX;_p*v4G_oA?32$P0OUPy)Uti!1=muPI0 z1z~xiClz?X3?f2(?#e*DCksjr#!oQ#uOME>F>2!unnGvb@~6?Kq=gdH)?%G#8@Pj0 zZwn{*qS4Y4Z3DG*qPL7hPmC35p)H((+W2>F*pv#CKh4owl zvDge~d8vgM)}^j>u$uMmY;-WOZWyDeWfCYzVtN>KD;|Jdg~aOM4g3Zs*g!1}h7Kds zGA=;o1~Slwo@NW>h7wM8Z^Zd3#_%CAdKpGq+{R;Y^{wyUy?6WC*0t}p-n+GV_oI71 z-rRcsi>*&?Z+`IQ*0-N;-nnyjJv%@5!rXJ`%aivpyimapwAtATUZ2}{px&IS@ce6C zr94}f+M%8cl&)){!mHeMe23**2P*kic{bg8w8DkSC~x>Nd`rFFoRH#>6jM?hT_1Ef zrz}>1kHKzm9;+0Y0f~sr0)=lcEV@SBMogI|oo0HD$e*Y;W6EROVPe>ZvwfSDUyd3+QCsaLKU#2nJ9GjnayEAssf-{7PpXul|K=Er znAmMEMl5h*o37P;Cv;-jnOR}DQ5doTnbUwgEWOBNzj3(VIMQ!Sr$!q1=``Ean4I_; zu_qwvgY)CdQ9#GtWiMPBuSRtmy1uhIz8bL=UUytN?$qnd zr~@ztIFWvdHSlEZ>K z_F)U~O$s~Cy$*Z+%ehy!&wca%o%=6X{^oC3E;b&4%BNVq*ZFE2zrS>DZNB~4<_~wz zu1|JREZe#K{JEE2Eb~jg7gIZMIP^vN!s)qlbLF^Mr?dN^9eldqm2mGp5?m5>^Dy`b zh$UdiS53)@1nos1YL_%1Y|P|4sy{25(5irE7Og5f6;Hyl$j|s5k0lh3?~&nT>=CrS zU$ve(o7*m|#YRY*=a~DHOS67ikP<`#zHuU^02OSBg<79+F~A ziif3;U!-EON2M-lfIS7GALJAL#-V=1PxTu|{#w4>|4^r05StHy(5@8p5+1=NyoT3t z^*1&R&dR9B_Q+1_1XmlGE^j{6Wu9t}T+$L}V2znl$g&HoyNgM_5k}y!6-6`Z9(|RN j6v$LJWoO_ozTwm3vh`GJK8~`u0KYL9rk2O1If(xN4FiI5 literal 0 HcmV?d00001 diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py new file mode 100644 index 0000000..e40bb8f --- /dev/null +++ b/backend/tests/test_database.py @@ -0,0 +1,23 @@ +import pytest +import asyncio +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession +from backend.database import engine, Base, get_db, init_db +from backend import models # 导入模型以注册表 + +@pytest.mark.asyncio +async def test_database_connection(): + """测试数据库连接是否正常""" + async with engine.begin() as conn: + result = await conn.execute(text("SELECT 1")) + assert result.scalar() == 1 + +@pytest.mark.asyncio +async def test_tables_created(): + """测试表是否正确创建""" + await init_db() + async with engine.begin() as conn: + result = await conn.execute(text("SELECT name FROM sqlite_master WHERE type='table'")) + tables = [row[0] for row in result.fetchall()] + assert "channels" in tables + assert "notifications" in tables