Compare commits

...

3 Commits

Author SHA1 Message Date
shokollm
7c17bff110 feat: add category service layer methods
Service layer for category feature (Issue #86):

Category Management:
- add_category() - Create category (admin only, validates slug format)
- delete_category() - Soft delete category (admin only)
- list_categories() - List active categories
- get_category() - Get category by slug

Category-to-Bounty Association:
- add_category_to_bounty() - Add category to bounty (admin only)
- remove_category_from_bounty() - Remove category from bounty (admin only)
- update_bounty_categories() - Replace all categories on bounty (admin only)

All methods properly validate permissions, slug format, and existence.
Soft delete preserves category data for bounties that reference it.
2026-04-09 10:38:08 +00:00
43d07eae92 Merge pull request 'feat: Category Feature - Models & Storage (#85)' (#90) from feature/category-models-storage into main 2026-04-09 12:35:50 +02:00
shokollm
66d2a9fb86 feat: add Category model and update storage for categories
Models & Storage layer for category feature (Issue #85):

- Add Category dataclass to core/models.py
  - id (slug): lowercase alphabetic only
  - name: display name
  - created_at: Unix timestamp
  - deleted_at: soft delete timestamp (None if active)

- Add category_ids field to Bounty dataclass
  - list[str] for multiple categories per bounty
  - Default empty list for backward compatibility

- Add categories field to RoomData dataclass
  - list[Category] for room-level categories
  - Default empty list

- Update JsonFileRoomStorage to serialize/deserialize:
  - Category fields (id, name, created_at, deleted_at)
  - Bounty.category_ids
  - RoomData.categories

Backward compatible: existing data without categories works fine.
2026-04-09 10:23:33 +00:00
3 changed files with 274 additions and 3 deletions

View File

@@ -11,7 +11,7 @@ import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from core.models import Bounty, RoomData, TrackingData, TrackedBounty from core.models import Bounty, Category, RoomData, TrackingData, TrackedBounty
class JsonFileRoomStorage: class JsonFileRoomStorage:
@@ -57,16 +57,28 @@ class JsonFileRoomStorage:
created_by_user_id=b["created_by_user_id"], created_by_user_id=b["created_by_user_id"],
deleted_at=b.get("deleted_at"), deleted_at=b.get("deleted_at"),
created_by_username=b.get("created_by_username"), created_by_username=b.get("created_by_username"),
category_ids=b.get("category_ids", []),
) )
for b in data.get("bounties", []) for b in data.get("bounties", [])
] ]
categories = [
Category(
id=c["id"],
name=c["name"],
created_at=c["created_at"],
deleted_at=c.get("deleted_at"),
)
for c in data.get("categories", [])
]
return RoomData( return RoomData(
room_id=data["room_id"], room_id=data["room_id"],
bounties=bounties, bounties=bounties,
next_id=data["next_id"], next_id=data["next_id"],
timezone=data.get("timezone"), timezone=data.get("timezone"),
admin_usernames=data.get("admin_usernames", []), admin_usernames=data.get("admin_usernames", []),
categories=categories,
) )
def save(self, room_data: RoomData) -> None: def save(self, room_data: RoomData) -> None:
@@ -76,6 +88,15 @@ class JsonFileRoomStorage:
"next_id": room_data.next_id, "next_id": room_data.next_id,
"timezone": room_data.timezone, "timezone": room_data.timezone,
"admin_usernames": room_data.admin_usernames or [], "admin_usernames": room_data.admin_usernames or [],
"categories": [
{
"id": c.id,
"name": c.name,
"created_at": c.created_at,
"deleted_at": c.deleted_at,
}
for c in room_data.categories
],
"bounties": [ "bounties": [
{ {
"id": b.id, "id": b.id,
@@ -86,6 +107,7 @@ class JsonFileRoomStorage:
"created_by_user_id": b.created_by_user_id, "created_by_user_id": b.created_by_user_id,
"deleted_at": b.deleted_at, "deleted_at": b.deleted_at,
"created_by_username": b.created_by_username, "created_by_username": b.created_by_username,
"category_ids": b.category_ids,
} }
for b in room_data.bounties for b in room_data.bounties
], ],

View File

@@ -1,6 +1,20 @@
"""Domain dataclasses for JIGAIDO bounty tracker.""" """Domain dataclasses for JIGAIDO bounty tracker."""
from dataclasses import dataclass from dataclasses import dataclass, field
@dataclass
class Category:
"""A category for organizing bounties in a room.
Categories are per-room and support soft delete.
The id (slug) must be lowercase alphabetic only (e.g., "bug", "feature").
"""
id: str # slug: lowercase alphabetic only, e.g., "bug", "feature"
name: str # display name: e.g., "Bug", "Feature"
created_at: int
deleted_at: int | None = None # soft delete
@dataclass @dataclass
@@ -12,6 +26,8 @@ class Bounty:
The deleted_at field indicates soft-delete: None means not deleted, The deleted_at field indicates soft-delete: None means not deleted,
a value means deleted at that Unix timestamp. a value means deleted at that Unix timestamp.
The category_ids field lists category slugs associated with this bounty.
""" """
id: int id: int
@@ -22,6 +38,7 @@ class Bounty:
created_by_user_id: int created_by_user_id: int
deleted_at: int | None = None deleted_at: int | None = None
created_by_username: str | None = None created_by_username: str | None = None
category_ids: list[str] = field(default_factory=list)
@dataclass @dataclass
@@ -45,6 +62,7 @@ class RoomData:
The timezone field stores the room's timezone (e.g., "Asia/Jakarta"), default UTC+0. The timezone field stores the room's timezone (e.g., "Asia/Jakarta"), default UTC+0.
The admin_usernames field lists usernames who have admin privileges in this room. The admin_usernames field lists usernames who have admin privileges in this room.
The categories field contains all categories for organizing bounties in this room.
""" """
room_id: int room_id: int
@@ -52,10 +70,13 @@ class RoomData:
next_id: int next_id: int
timezone: str | None = None timezone: str | None = None
admin_usernames: list[str] | None = None admin_usernames: list[str] | None = None
categories: list[Category] = field(default_factory=list)
def __post_init__(self): def __post_init__(self):
if self.admin_usernames is None: if self.admin_usernames is None:
self.admin_usernames = [] self.admin_usernames = []
if self.categories is None:
self.categories = []
@dataclass @dataclass

View File

@@ -3,7 +3,7 @@
import time import time
from typing import Optional from typing import Optional
from core.models import Bounty, RoomData, TrackedBounty, TrackingData from core.models import Bounty, Category, RoomData, TrackedBounty, TrackingData
from core.ports import RoomStorage, TrackingStorage from core.ports import RoomStorage, TrackingStorage
@@ -289,6 +289,234 @@ class BountyService:
results[bounty_id] = "deleted" results[bounty_id] = "deleted"
return results return results
# --- Category Management ---
def add_category(
self,
room_id: int,
slug: str,
name: str,
username: str | None,
) -> Category:
"""Create a new category. Admin only.
Args:
room_id: Room identifier
slug: Category ID (lowercase alphabetic, e.g., "bug")
name: Display name (e.g., "Bug Report")
username: Requesting admin's username
Returns:
Created Category
Raises:
PermissionError: If not admin
ValueError: If slug already exists or invalid
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can add categories.")
# Validate slug format (lowercase alphabetic only)
if not slug or not slug.isalpha() or not slug.islower():
raise ValueError(
"Category slug must be lowercase alphabetic only (e.g., 'bug', 'feature')."
)
room_data = self._storage.load(room_id)
if room_data is None:
room_data = RoomData(
room_id=room_id, bounties=[], next_id=1, admin_usernames=[], categories=[]
)
# Check for duplicate slug
for cat in room_data.categories:
if cat.id == slug and cat.deleted_at is None:
raise ValueError(f"Category '{slug}' already exists.")
category = Category(
id=slug,
name=name,
created_at=int(time.time()),
deleted_at=None,
)
room_data.categories.append(category)
self._storage.save(room_data)
return category
def delete_category(
self,
room_id: int,
slug: str,
username: str | None,
) -> bool:
"""Soft delete a category. Admin only.
Args:
room_id: Room identifier
slug: Category slug to delete
username: Requesting admin's username
Returns:
True if deleted, False if not found
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can delete categories.")
room_data = self._storage.load(room_id)
if room_data is None:
return False
for cat in room_data.categories:
if cat.id == slug and cat.deleted_at is None:
cat.deleted_at = int(time.time())
self._storage.save(room_data)
return True
return False
def list_categories(self, room_id: int) -> list[Category]:
"""List active categories (excludes soft-deleted).
Args:
room_id: Room identifier
Returns:
List of active categories
"""
room_data = self._storage.load(room_id)
if room_data is None:
return []
return [c for c in room_data.categories if c.deleted_at is None]
def get_category(self, room_id: int, slug: str) -> Category | None:
"""Get a category by slug (excludes soft-deleted).
Args:
room_id: Room identifier
slug: Category slug
Returns:
Category or None if not found
"""
room_data = self._storage.load(room_id)
if room_data is None:
return None
for cat in room_data.categories:
if cat.id == slug and cat.deleted_at is None:
return cat
return None
def _validate_category_exists(self, room_id: int, slug: str) -> None:
"""Validate that a category exists (and is not deleted). Raises ValueError if not found."""
if not self.get_category(room_id, slug):
raise ValueError(f"Category '{slug}' not found.")
def add_category_to_bounty(
self,
room_id: int,
bounty_id: int,
category_slug: str,
username: str | None,
) -> bool:
"""Add category to a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slug: Category slug to add
username: Requesting admin's username
Returns:
True if newly added, False if already exists
Raises:
PermissionError: If not admin
ValueError: If bounty or category not found
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can manage bounty categories.")
bounty = self.get_bounty(room_id, bounty_id)
if not bounty:
raise ValueError("Bounty not found.")
self._validate_category_exists(room_id, category_slug)
if category_slug in bounty.category_ids:
return False # Already exists
bounty.category_ids.append(category_slug)
self._storage.update_bounty(room_id, bounty)
return True
def remove_category_from_bounty(
self,
room_id: int,
bounty_id: int,
category_slug: str,
username: str | None,
) -> bool:
"""Remove category from a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slug: Category slug to remove
username: Requesting admin's username
Returns:
True if removed, False if not found
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can manage bounty categories.")
bounty = self.get_bounty(room_id, bounty_id)
if not bounty:
raise ValueError("Bounty not found.")
if category_slug not in bounty.category_ids:
return False
bounty.category_ids.remove(category_slug)
self._storage.update_bounty(room_id, bounty)
return True
def update_bounty_categories(
self,
room_id: int,
bounty_id: int,
category_slugs: list[str],
username: str | None,
) -> bool:
"""Replace all categories on a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slugs: New list of category slugs
username: Requesting admin's username
Returns:
True if updated
Raises:
PermissionError: If not admin
ValueError: If bounty or any category not found
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can manage bounty categories.")
bounty = self.get_bounty(room_id, bounty_id)
if not bounty:
raise ValueError("Bounty not found.")
# Validate all categories exist
for slug in category_slugs:
self._validate_category_exists(room_id, slug)
bounty.category_ids = category_slugs
self._storage.update_bounty(room_id, bounty)
return True
class TrackingService: class TrackingService:
"""Service for tracking bounty operations.""" """Service for tracking bounty operations."""