msaas-feedback 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
feedback/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """User feedback and feature request management for SaaS."""
2
+
3
+ from feedback.analysis import FeedbackAnalyzer
4
+ from feedback.config import Feedback, get_feedback, init_feedback, reset_feedback
5
+ from feedback.models import (
6
+ FeedbackCategory,
7
+ FeedbackComment,
8
+ FeedbackConfig,
9
+ FeedbackFilter,
10
+ FeedbackItem,
11
+ FeedbackPriority,
12
+ FeedbackSortBy,
13
+ FeedbackStats,
14
+ FeedbackStatus,
15
+ FeedbackVote,
16
+ PaginatedResult,
17
+ )
18
+ from feedback.router import create_feedback_router
19
+ from feedback.service import FeedbackService
20
+ from feedback.store import FeedbackStore, InMemoryStore
21
+
22
+ __all__ = [
23
+ "Feedback",
24
+ "FeedbackAnalyzer",
25
+ "FeedbackCategory",
26
+ "FeedbackComment",
27
+ "FeedbackConfig",
28
+ "FeedbackFilter",
29
+ "FeedbackItem",
30
+ "FeedbackPriority",
31
+ "FeedbackService",
32
+ "FeedbackSortBy",
33
+ "FeedbackStats",
34
+ "FeedbackStatus",
35
+ "FeedbackStore",
36
+ "FeedbackVote",
37
+ "InMemoryStore",
38
+ "PaginatedResult",
39
+ "create_feedback_router",
40
+ "get_feedback",
41
+ "init_feedback",
42
+ "reset_feedback",
43
+ ]
feedback/analysis.py ADDED
@@ -0,0 +1,70 @@
1
+ """Feedback analytics and insights."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime, timedelta
6
+
7
+ from feedback.models import (
8
+ FeedbackItem,
9
+ FeedbackStatus,
10
+ )
11
+ from feedback.store import FeedbackStore
12
+
13
+ _TERMINAL_STATUSES = {FeedbackStatus.DONE, FeedbackStatus.DECLINED}
14
+
15
+
16
+ class FeedbackAnalyzer:
17
+ """Provides analytical views over the feedback dataset."""
18
+
19
+ def __init__(self, store: FeedbackStore) -> None:
20
+ self._store = store
21
+
22
+ async def trending(self, *, days: int = 7, limit: int = 10) -> list[FeedbackItem]:
23
+ """Return the most-voted feedback items created within the last *days*.
24
+
25
+ Items that have been merged or resolved are excluded.
26
+ """
27
+ cutoff = datetime.now(UTC) - timedelta(days=days)
28
+ all_items = await self._store.list_all_items()
29
+
30
+ recent = [
31
+ item
32
+ for item in all_items
33
+ if item.created_at >= cutoff
34
+ and item.merged_into is None
35
+ and item.status not in _TERMINAL_STATUSES
36
+ ]
37
+ recent.sort(key=lambda i: i.vote_count, reverse=True)
38
+ return recent[:limit]
39
+
40
+ async def stale(self, *, days: int = 30) -> list[FeedbackItem]:
41
+ """Return open items that have not been updated in *days*."""
42
+ cutoff = datetime.now(UTC) - timedelta(days=days)
43
+ all_items = await self._store.list_all_items()
44
+
45
+ return [
46
+ item
47
+ for item in all_items
48
+ if item.updated_at < cutoff
49
+ and item.merged_into is None
50
+ and item.status not in _TERMINAL_STATUSES
51
+ ]
52
+
53
+ async def category_distribution(self) -> dict[str, int]:
54
+ """Return a breakdown of non-merged items by category."""
55
+ stats = await self._store.get_stats()
56
+ return stats.by_category
57
+
58
+ async def resolution_rate(self) -> float:
59
+ """Return the percentage of items that have been resolved (done/declined).
60
+
61
+ Returns 0.0 if there are no items.
62
+ """
63
+ all_items = await self._store.list_all_items()
64
+ non_merged = [i for i in all_items if i.merged_into is None]
65
+
66
+ if not non_merged:
67
+ return 0.0
68
+
69
+ resolved = sum(1 for i in non_merged if i.status in _TERMINAL_STATUSES)
70
+ return (resolved / len(non_merged)) * 100
feedback/config.py ADDED
@@ -0,0 +1,71 @@
1
+ """Global configuration and singleton management for the feedback system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from feedback.models import FeedbackConfig
6
+ from feedback.service import FeedbackService
7
+ from feedback.store import FeedbackStore, InMemoryStore
8
+
9
+
10
+ class Feedback:
11
+ """Facade grouping the feedback service, store, and config."""
12
+
13
+ def __init__(
14
+ self,
15
+ *,
16
+ config: FeedbackConfig,
17
+ store: FeedbackStore,
18
+ service: FeedbackService,
19
+ ) -> None:
20
+ self.config = config
21
+ self.store = store
22
+ self.service = service
23
+
24
+
25
+ # Module-level singleton
26
+ _instance: Feedback | None = None
27
+
28
+
29
+ def init_feedback(
30
+ config: FeedbackConfig | None = None,
31
+ *,
32
+ store: FeedbackStore | None = None,
33
+ ) -> Feedback:
34
+ """Initialize the global feedback system.
35
+
36
+ Call once at application startup. If no store is provided, an
37
+ InMemoryStore is used (suitable for testing/development).
38
+
39
+ Args:
40
+ config: Feedback configuration. Defaults to FeedbackConfig().
41
+ store: Storage backend. Defaults to InMemoryStore.
42
+
43
+ Returns:
44
+ The initialized Feedback facade.
45
+ """
46
+ global _instance # noqa: PLW0603
47
+ config = config or FeedbackConfig()
48
+ store = store or InMemoryStore()
49
+
50
+ service = FeedbackService(store, config=config)
51
+
52
+ _instance = Feedback(config=config, store=store, service=service)
53
+ return _instance
54
+
55
+
56
+ def get_feedback() -> Feedback:
57
+ """Retrieve the global Feedback instance.
58
+
59
+ Raises:
60
+ RuntimeError: If init_feedback() has not been called.
61
+ """
62
+ if _instance is None:
63
+ msg = "Feedback not initialized. Call init_feedback() first."
64
+ raise RuntimeError(msg)
65
+ return _instance
66
+
67
+
68
+ def reset_feedback() -> None:
69
+ """Reset global feedback state. Useful for testing."""
70
+ global _instance # noqa: PLW0603
71
+ _instance = None
feedback/models.py ADDED
@@ -0,0 +1,124 @@
1
+ """Pydantic v2 domain models for the feedback system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from enum import StrEnum
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class FeedbackCategory(StrEnum):
12
+ """Type of feedback submitted by a user."""
13
+
14
+ BUG = "bug"
15
+ FEATURE = "feature"
16
+ IMPROVEMENT = "improvement"
17
+ QUESTION = "question"
18
+
19
+
20
+ class FeedbackStatus(StrEnum):
21
+ """Lifecycle status of a feedback item."""
22
+
23
+ NEW = "new"
24
+ UNDER_REVIEW = "under_review"
25
+ PLANNED = "planned"
26
+ IN_PROGRESS = "in_progress"
27
+ DONE = "done"
28
+ DECLINED = "declined"
29
+
30
+
31
+ class FeedbackPriority(StrEnum):
32
+ """Urgency/importance level of a feedback item."""
33
+
34
+ LOW = "low"
35
+ MEDIUM = "medium"
36
+ HIGH = "high"
37
+ CRITICAL = "critical"
38
+
39
+
40
+ class FeedbackSortBy(StrEnum):
41
+ """Sorting options for feedback listings."""
42
+
43
+ NEWEST = "newest"
44
+ OLDEST = "oldest"
45
+ VOTES = "votes"
46
+
47
+
48
+ class FeedbackConfig(BaseModel):
49
+ """Configuration for the feedback module."""
50
+
51
+ categories: list[FeedbackCategory] = Field(
52
+ default_factory=lambda: list(FeedbackCategory),
53
+ )
54
+ allow_anonymous: bool = True
55
+ require_moderation: bool = False
56
+ max_votes_per_user: int = 50
57
+
58
+
59
+ class FeedbackItem(BaseModel):
60
+ """A single piece of user feedback or feature request."""
61
+
62
+ id: str
63
+ title: str
64
+ description: str
65
+ category: FeedbackCategory
66
+ status: FeedbackStatus = FeedbackStatus.NEW
67
+ priority: FeedbackPriority = FeedbackPriority.MEDIUM
68
+ author_id: str | None = None
69
+ author_email: str | None = None
70
+ is_anonymous: bool = False
71
+ tags: list[str] = Field(default_factory=list)
72
+ vote_count: int = 0
73
+ merged_into: str | None = None
74
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
75
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
76
+ resolved_at: datetime | None = None
77
+
78
+
79
+ class FeedbackVote(BaseModel):
80
+ """A vote cast by a user on a feedback item."""
81
+
82
+ item_id: str
83
+ user_id: str
84
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
85
+
86
+
87
+ class FeedbackComment(BaseModel):
88
+ """A comment on a feedback item."""
89
+
90
+ id: str
91
+ item_id: str
92
+ author_id: str
93
+ content: str
94
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
95
+
96
+
97
+ class FeedbackFilter(BaseModel):
98
+ """Filtering and sorting criteria for feedback listings."""
99
+
100
+ category: FeedbackCategory | None = None
101
+ status: FeedbackStatus | None = None
102
+ priority: FeedbackPriority | None = None
103
+ tag: str | None = None
104
+ search_query: str | None = None
105
+ sort_by: FeedbackSortBy = FeedbackSortBy.NEWEST
106
+
107
+
108
+ class PaginatedResult(BaseModel):
109
+ """Paginated list of feedback items."""
110
+
111
+ items: list[FeedbackItem]
112
+ total: int
113
+ page: int
114
+ per_page: int
115
+
116
+
117
+ class FeedbackStats(BaseModel):
118
+ """Aggregate statistics for the feedback system."""
119
+
120
+ total: int = 0
121
+ by_category: dict[str, int] = Field(default_factory=dict)
122
+ by_status: dict[str, int] = Field(default_factory=dict)
123
+ by_priority: dict[str, int] = Field(default_factory=dict)
124
+ avg_resolution_hours: float | None = None
feedback/router.py ADDED
@@ -0,0 +1,218 @@
1
+ """FastAPI router factory for feedback endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from errors import (
8
+ NotFoundError,
9
+ ValidationError,
10
+ )
11
+ from fastapi import APIRouter, Query
12
+ from pydantic import BaseModel
13
+
14
+ from feedback.config import get_feedback
15
+ from feedback.models import (
16
+ FeedbackCategory,
17
+ FeedbackComment,
18
+ FeedbackFilter,
19
+ FeedbackItem,
20
+ FeedbackPriority,
21
+ FeedbackSortBy,
22
+ FeedbackStats,
23
+ FeedbackStatus,
24
+ PaginatedResult,
25
+ )
26
+
27
+ # --- Request / Response models ---
28
+
29
+
30
+ class SubmitFeedbackRequest(BaseModel):
31
+ """Request body for submitting new feedback."""
32
+
33
+ title: str
34
+ description: str
35
+ category: FeedbackCategory
36
+ author_id: str | None = None
37
+ author_email: str | None = None
38
+ tags: list[str] | None = None
39
+
40
+
41
+ class UpdateStatusRequest(BaseModel):
42
+ """Request body for updating feedback status."""
43
+
44
+ status: FeedbackStatus
45
+
46
+
47
+ class UpdatePriorityRequest(BaseModel):
48
+ """Request body for updating feedback priority."""
49
+
50
+ priority: FeedbackPriority
51
+
52
+
53
+ class VoteRequest(BaseModel):
54
+ """Request body for voting."""
55
+
56
+ user_id: str
57
+
58
+
59
+ class AddCommentRequest(BaseModel):
60
+ """Request body for adding a comment."""
61
+
62
+ author_id: str
63
+ content: str
64
+
65
+
66
+ class TrendingItem(BaseModel):
67
+ """Response model for trending items."""
68
+
69
+ items: list[FeedbackItem]
70
+
71
+
72
+ # --- Router factory ---
73
+
74
+
75
+ def create_feedback_router(
76
+ *,
77
+ prefix: str = "/feedback",
78
+ tags: list[str] | None = None,
79
+ dependencies: list[Any] | None = None,
80
+ ) -> APIRouter:
81
+ """Create a FastAPI router for feedback endpoints.
82
+
83
+ Args:
84
+ prefix: URL prefix for all routes. Defaults to ``/feedback``.
85
+ tags: OpenAPI tags for the router.
86
+ dependencies: Optional list of FastAPI dependencies for all routes.
87
+
88
+ Returns:
89
+ A configured APIRouter.
90
+ """
91
+ router = APIRouter(
92
+ prefix=prefix,
93
+ tags=tags or ["feedback"],
94
+ dependencies=dependencies or [],
95
+ )
96
+
97
+ @router.post("", response_model=FeedbackItem, status_code=201)
98
+ async def submit_feedback(body: SubmitFeedbackRequest) -> FeedbackItem:
99
+ """Submit new feedback."""
100
+ fb = get_feedback()
101
+ try:
102
+ return await fb.service.submit(
103
+ title=body.title,
104
+ description=body.description,
105
+ category=body.category,
106
+ author_id=body.author_id,
107
+ author_email=body.author_email,
108
+ tags=body.tags,
109
+ )
110
+ except ValueError as exc:
111
+ raise ValidationError(str(exc)) from exc
112
+
113
+ @router.get("/stats", response_model=FeedbackStats)
114
+ async def get_stats() -> FeedbackStats:
115
+ """Get aggregate feedback statistics."""
116
+ fb = get_feedback()
117
+ return await fb.service.get_stats()
118
+
119
+ @router.get("/trending", response_model=TrendingItem)
120
+ async def get_trending(
121
+ days: int = Query(7, ge=1, description="Number of days to look back"),
122
+ limit: int = Query(10, ge=1, le=100, description="Maximum items to return"),
123
+ ) -> TrendingItem:
124
+ """Get trending feedback items."""
125
+ from feedback.analysis import FeedbackAnalyzer
126
+
127
+ fb = get_feedback()
128
+ analyzer = FeedbackAnalyzer(fb.store)
129
+ items = await analyzer.trending(days=days, limit=limit)
130
+ return TrendingItem(items=items)
131
+
132
+ @router.get("", response_model=PaginatedResult)
133
+ async def list_feedback(
134
+ category: FeedbackCategory | None = Query(None, description="Filter by category"),
135
+ status: FeedbackStatus | None = Query(None, description="Filter by status"),
136
+ priority: FeedbackPriority | None = Query(None, description="Filter by priority"),
137
+ tag: str | None = Query(None, description="Filter by tag"),
138
+ search: str | None = Query(None, description="Search in title/description"),
139
+ sort_by: FeedbackSortBy = Query(FeedbackSortBy.NEWEST, description="Sort order"),
140
+ page: int = Query(1, ge=1, description="Page number"),
141
+ per_page: int = Query(20, ge=1, le=100, description="Items per page"),
142
+ ) -> PaginatedResult:
143
+ """List feedback items with pagination and filters."""
144
+ fb = get_feedback()
145
+ filter_ = FeedbackFilter(
146
+ category=category,
147
+ status=status,
148
+ priority=priority,
149
+ tag=tag,
150
+ search_query=search,
151
+ sort_by=sort_by,
152
+ )
153
+ return await fb.service.list_items(filter_, page=page, per_page=per_page)
154
+
155
+ @router.get("/{item_id}", response_model=FeedbackItem)
156
+ async def get_feedback_item(item_id: str) -> FeedbackItem:
157
+ """Get a single feedback item."""
158
+ fb = get_feedback()
159
+ item = await fb.service.get_item(item_id)
160
+ if item is None:
161
+ raise NotFoundError("Feedback item not found")
162
+ return item
163
+
164
+ @router.put("/{item_id}/status", response_model=FeedbackItem)
165
+ async def update_status(item_id: str, body: UpdateStatusRequest) -> FeedbackItem:
166
+ """Update the status of a feedback item."""
167
+ fb = get_feedback()
168
+ try:
169
+ return await fb.service.update_status(item_id, body.status)
170
+ except ValueError as exc:
171
+ raise NotFoundError(str(exc)) from exc
172
+
173
+ @router.put("/{item_id}/priority", response_model=FeedbackItem)
174
+ async def update_priority(item_id: str, body: UpdatePriorityRequest) -> FeedbackItem:
175
+ """Update the priority of a feedback item."""
176
+ fb = get_feedback()
177
+ try:
178
+ return await fb.service.update_priority(item_id, body.priority)
179
+ except ValueError as exc:
180
+ raise NotFoundError(str(exc)) from exc
181
+
182
+ @router.post("/{item_id}/vote", response_model=FeedbackItem)
183
+ async def vote_feedback(item_id: str, body: VoteRequest) -> FeedbackItem:
184
+ """Vote on a feedback item."""
185
+ fb = get_feedback()
186
+ try:
187
+ return await fb.service.vote(item_id, body.user_id)
188
+ except ValueError as exc:
189
+ raise ValidationError(str(exc)) from exc
190
+
191
+ @router.delete("/{item_id}/vote", response_model=FeedbackItem)
192
+ async def unvote_feedback(item_id: str, body: VoteRequest) -> FeedbackItem:
193
+ """Remove a vote from a feedback item."""
194
+ fb = get_feedback()
195
+ try:
196
+ return await fb.service.unvote(item_id, body.user_id)
197
+ except ValueError as exc:
198
+ raise NotFoundError(str(exc)) from exc
199
+
200
+ @router.post("/{item_id}/comments", response_model=FeedbackComment, status_code=201)
201
+ async def add_comment(item_id: str, body: AddCommentRequest) -> FeedbackComment:
202
+ """Add a comment to a feedback item."""
203
+ fb = get_feedback()
204
+ try:
205
+ return await fb.service.add_comment(item_id, body.author_id, body.content)
206
+ except ValueError as exc:
207
+ raise ValidationError(str(exc)) from exc
208
+
209
+ @router.get("/{item_id}/comments", response_model=list[FeedbackComment])
210
+ async def list_comments(item_id: str) -> list[FeedbackComment]:
211
+ """List comments for a feedback item."""
212
+ fb = get_feedback()
213
+ try:
214
+ return await fb.service.get_comments(item_id)
215
+ except ValueError as exc:
216
+ raise NotFoundError(str(exc)) from exc
217
+
218
+ return router
feedback/service.py ADDED
@@ -0,0 +1,260 @@
1
+ """Core business logic for the feedback system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import UTC, datetime
7
+
8
+ from errors import NotFoundError, ValidationError
9
+
10
+ from feedback.models import (
11
+ FeedbackCategory,
12
+ FeedbackComment,
13
+ FeedbackConfig,
14
+ FeedbackFilter,
15
+ FeedbackItem,
16
+ FeedbackPriority,
17
+ FeedbackStats,
18
+ FeedbackStatus,
19
+ FeedbackVote,
20
+ PaginatedResult,
21
+ )
22
+ from feedback.store import FeedbackStore
23
+
24
+ _TERMINAL_STATUSES = {FeedbackStatus.DONE, FeedbackStatus.DECLINED}
25
+
26
+
27
+ class FeedbackService:
28
+ """Orchestrates feedback operations with business rules."""
29
+
30
+ def __init__(self, store: FeedbackStore, *, config: FeedbackConfig) -> None:
31
+ self._store = store
32
+ self._config = config
33
+
34
+ async def submit(
35
+ self,
36
+ title: str,
37
+ description: str,
38
+ category: FeedbackCategory | str,
39
+ *,
40
+ author_id: str | None = None,
41
+ author_email: str | None = None,
42
+ tags: list[str] | None = None,
43
+ ) -> FeedbackItem:
44
+ """Create a new feedback item.
45
+
46
+ Raises:
47
+ ValueError: If anonymous submissions are disabled and no author is given,
48
+ or if the category is not allowed.
49
+ """
50
+ resolved_category = FeedbackCategory(category) if isinstance(category, str) else category
51
+
52
+ if resolved_category not in self._config.categories:
53
+ msg = f"Category '{resolved_category.value}' is not allowed"
54
+ raise ValidationError(msg)
55
+
56
+ is_anonymous = author_id is None and author_email is None
57
+ if is_anonymous and not self._config.allow_anonymous:
58
+ msg = "Anonymous feedback is not allowed"
59
+ raise ValidationError(msg)
60
+
61
+ now = datetime.now(UTC)
62
+ item = FeedbackItem(
63
+ id=uuid.uuid4().hex,
64
+ title=title,
65
+ description=description,
66
+ category=resolved_category,
67
+ author_id=author_id,
68
+ author_email=author_email,
69
+ is_anonymous=is_anonymous,
70
+ tags=tags or [],
71
+ created_at=now,
72
+ updated_at=now,
73
+ )
74
+ return await self._store.save_item(item)
75
+
76
+ async def update_status(self, item_id: str, status: FeedbackStatus | str) -> FeedbackItem:
77
+ """Change the status of a feedback item.
78
+
79
+ Raises:
80
+ ValueError: If the item does not exist.
81
+ """
82
+ item = await self._store.get_item(item_id)
83
+ if item is None:
84
+ msg = f"Feedback item '{item_id}' not found"
85
+ raise NotFoundError(msg)
86
+
87
+ resolved_status = FeedbackStatus(status) if isinstance(status, str) else status
88
+ now = datetime.now(UTC)
89
+ resolved_at = now if resolved_status in _TERMINAL_STATUSES else item.resolved_at
90
+
91
+ updated = item.model_copy(
92
+ update={
93
+ "status": resolved_status,
94
+ "updated_at": now,
95
+ "resolved_at": resolved_at,
96
+ }
97
+ )
98
+ return await self._store.save_item(updated)
99
+
100
+ async def update_priority(self, item_id: str, priority: FeedbackPriority | str) -> FeedbackItem:
101
+ """Change the priority of a feedback item.
102
+
103
+ Raises:
104
+ ValueError: If the item does not exist.
105
+ """
106
+ item = await self._store.get_item(item_id)
107
+ if item is None:
108
+ msg = f"Feedback item '{item_id}' not found"
109
+ raise NotFoundError(msg)
110
+
111
+ resolved_priority = FeedbackPriority(priority) if isinstance(priority, str) else priority
112
+ updated = item.model_copy(
113
+ update={
114
+ "priority": resolved_priority,
115
+ "updated_at": datetime.now(UTC),
116
+ }
117
+ )
118
+ return await self._store.save_item(updated)
119
+
120
+ async def vote(self, item_id: str, user_id: str) -> FeedbackItem:
121
+ """Cast a vote on a feedback item.
122
+
123
+ Raises:
124
+ ValueError: If the item does not exist, or the user has reached the vote limit.
125
+ """
126
+ item = await self._store.get_item(item_id)
127
+ if item is None:
128
+ msg = f"Feedback item '{item_id}' not found"
129
+ raise NotFoundError(msg)
130
+
131
+ existing = await self._store.get_vote(item_id, user_id)
132
+ if existing is not None:
133
+ return item # Already voted, idempotent
134
+
135
+ user_vote_count = await self._store.count_user_votes(user_id)
136
+ if user_vote_count >= self._config.max_votes_per_user:
137
+ msg = f"User '{user_id}' has reached the maximum of {self._config.max_votes_per_user} votes"
138
+ raise ValidationError(msg)
139
+
140
+ vote = FeedbackVote(item_id=item_id, user_id=user_id)
141
+ await self._store.save_vote(vote)
142
+
143
+ updated = item.model_copy(update={"vote_count": item.vote_count + 1})
144
+ return await self._store.save_item(updated)
145
+
146
+ async def unvote(self, item_id: str, user_id: str) -> FeedbackItem:
147
+ """Remove a vote from a feedback item.
148
+
149
+ Raises:
150
+ ValueError: If the item does not exist.
151
+ """
152
+ item = await self._store.get_item(item_id)
153
+ if item is None:
154
+ msg = f"Feedback item '{item_id}' not found"
155
+ raise NotFoundError(msg)
156
+
157
+ removed = await self._store.remove_vote(item_id, user_id)
158
+ if not removed:
159
+ return item # No vote to remove, idempotent
160
+
161
+ updated = item.model_copy(update={"vote_count": max(0, item.vote_count - 1)})
162
+ return await self._store.save_item(updated)
163
+
164
+ async def add_comment(self, item_id: str, author_id: str, content: str) -> FeedbackComment:
165
+ """Add a comment to a feedback item.
166
+
167
+ Raises:
168
+ ValueError: If the item does not exist or content is empty.
169
+ """
170
+ item = await self._store.get_item(item_id)
171
+ if item is None:
172
+ msg = f"Feedback item '{item_id}' not found"
173
+ raise NotFoundError(msg)
174
+
175
+ if not content.strip():
176
+ msg = "Comment content cannot be empty"
177
+ raise ValidationError(msg)
178
+
179
+ comment = FeedbackComment(
180
+ id=uuid.uuid4().hex,
181
+ item_id=item_id,
182
+ author_id=author_id,
183
+ content=content,
184
+ )
185
+ return await self._store.save_comment(comment)
186
+
187
+ async def list_items(
188
+ self,
189
+ filter_: FeedbackFilter | None = None,
190
+ *,
191
+ page: int = 1,
192
+ per_page: int = 20,
193
+ ) -> PaginatedResult:
194
+ """List feedback items with pagination, filtering, and sorting."""
195
+ filter_ = filter_ or FeedbackFilter()
196
+ return await self._store.list_items(filter_, page=page, per_page=per_page)
197
+
198
+ async def get_item(self, item_id: str) -> FeedbackItem | None:
199
+ """Get a single feedback item by ID."""
200
+ return await self._store.get_item(item_id)
201
+
202
+ async def get_comments(self, item_id: str) -> list[FeedbackComment]:
203
+ """List comments for a feedback item.
204
+
205
+ Raises:
206
+ ValueError: If the item does not exist.
207
+ """
208
+ item = await self._store.get_item(item_id)
209
+ if item is None:
210
+ msg = f"Feedback item '{item_id}' not found"
211
+ raise NotFoundError(msg)
212
+ return await self._store.list_comments(item_id)
213
+
214
+ async def get_stats(self) -> FeedbackStats:
215
+ """Get aggregate feedback statistics."""
216
+ return await self._store.get_stats()
217
+
218
+ async def merge(self, source_id: str, target_id: str) -> FeedbackItem:
219
+ """Merge duplicate feedback by folding source votes into target.
220
+
221
+ The source item is marked as merged and its votes are transferred.
222
+
223
+ Raises:
224
+ ValueError: If source or target does not exist, or they are the same.
225
+ """
226
+ if source_id == target_id:
227
+ msg = "Cannot merge an item into itself"
228
+ raise ValidationError(msg)
229
+
230
+ source = await self._store.get_item(source_id)
231
+ if source is None:
232
+ msg = f"Source feedback item '{source_id}' not found"
233
+ raise NotFoundError(msg)
234
+
235
+ target = await self._store.get_item(target_id)
236
+ if target is None:
237
+ msg = f"Target feedback item '{target_id}' not found"
238
+ raise NotFoundError(msg)
239
+
240
+ # Mark source as merged
241
+ merged_source = source.model_copy(
242
+ update={
243
+ "merged_into": target_id,
244
+ "status": FeedbackStatus.DECLINED,
245
+ "updated_at": datetime.now(UTC),
246
+ }
247
+ )
248
+ await self._store.save_item(merged_source)
249
+
250
+ # Transfer vote count to target
251
+ new_vote_count = target.vote_count + source.vote_count
252
+ merged_tags = list(dict.fromkeys([*target.tags, *source.tags]))
253
+ updated_target = target.model_copy(
254
+ update={
255
+ "vote_count": new_vote_count,
256
+ "tags": merged_tags,
257
+ "updated_at": datetime.now(UTC),
258
+ }
259
+ )
260
+ return await self._store.save_item(updated_target)
feedback/store.py ADDED
@@ -0,0 +1,177 @@
1
+ """Storage backends for feedback items, votes, and comments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from feedback.models import (
8
+ FeedbackComment,
9
+ FeedbackFilter,
10
+ FeedbackItem,
11
+ FeedbackSortBy,
12
+ FeedbackStats,
13
+ FeedbackVote,
14
+ PaginatedResult,
15
+ )
16
+
17
+
18
+ class FeedbackStore(ABC):
19
+ """Abstract base class for feedback persistence."""
20
+
21
+ @abstractmethod
22
+ async def save_item(self, item: FeedbackItem) -> FeedbackItem: ...
23
+
24
+ @abstractmethod
25
+ async def get_item(self, item_id: str) -> FeedbackItem | None: ...
26
+
27
+ @abstractmethod
28
+ async def delete_item(self, item_id: str) -> bool: ...
29
+
30
+ @abstractmethod
31
+ async def list_items(
32
+ self, filter_: FeedbackFilter, *, page: int = 1, per_page: int = 20
33
+ ) -> PaginatedResult: ...
34
+
35
+ @abstractmethod
36
+ async def save_vote(self, vote: FeedbackVote) -> bool: ...
37
+
38
+ @abstractmethod
39
+ async def remove_vote(self, item_id: str, user_id: str) -> bool: ...
40
+
41
+ @abstractmethod
42
+ async def get_vote(self, item_id: str, user_id: str) -> FeedbackVote | None: ...
43
+
44
+ @abstractmethod
45
+ async def count_user_votes(self, user_id: str) -> int: ...
46
+
47
+ @abstractmethod
48
+ async def save_comment(self, comment: FeedbackComment) -> FeedbackComment: ...
49
+
50
+ @abstractmethod
51
+ async def list_comments(self, item_id: str) -> list[FeedbackComment]: ...
52
+
53
+ @abstractmethod
54
+ async def get_stats(self) -> FeedbackStats: ...
55
+
56
+ @abstractmethod
57
+ async def list_all_items(self) -> list[FeedbackItem]: ...
58
+
59
+
60
+ class InMemoryStore(FeedbackStore):
61
+ """Dict-backed in-memory store for testing and development."""
62
+
63
+ def __init__(self) -> None:
64
+ self._items: dict[str, FeedbackItem] = {}
65
+ self._votes: dict[str, FeedbackVote] = {} # key: "item_id:user_id"
66
+ self._comments: dict[str, FeedbackComment] = {}
67
+
68
+ async def save_item(self, item: FeedbackItem) -> FeedbackItem:
69
+ self._items[item.id] = item
70
+ return item
71
+
72
+ async def get_item(self, item_id: str) -> FeedbackItem | None:
73
+ return self._items.get(item_id)
74
+
75
+ async def delete_item(self, item_id: str) -> bool:
76
+ if item_id in self._items:
77
+ del self._items[item_id]
78
+ return True
79
+ return False
80
+
81
+ async def list_items(
82
+ self, filter_: FeedbackFilter, *, page: int = 1, per_page: int = 20
83
+ ) -> PaginatedResult:
84
+ items = [i for i in self._items.values() if i.merged_into is None]
85
+ items = self._apply_filter(items, filter_)
86
+ items = self._apply_sort(items, filter_.sort_by)
87
+
88
+ total = len(items)
89
+ start = (max(1, page) - 1) * per_page
90
+ end = start + per_page
91
+ page_items = items[start:end]
92
+
93
+ return PaginatedResult(items=page_items, total=total, page=page, per_page=per_page)
94
+
95
+ async def save_vote(self, vote: FeedbackVote) -> bool:
96
+ key = f"{vote.item_id}:{vote.user_id}"
97
+ if key in self._votes:
98
+ return False
99
+ self._votes[key] = vote
100
+ return True
101
+
102
+ async def remove_vote(self, item_id: str, user_id: str) -> bool:
103
+ key = f"{item_id}:{user_id}"
104
+ if key in self._votes:
105
+ del self._votes[key]
106
+ return True
107
+ return False
108
+
109
+ async def get_vote(self, item_id: str, user_id: str) -> FeedbackVote | None:
110
+ return self._votes.get(f"{item_id}:{user_id}")
111
+
112
+ async def count_user_votes(self, user_id: str) -> int:
113
+ return sum(1 for v in self._votes.values() if v.user_id == user_id)
114
+
115
+ async def save_comment(self, comment: FeedbackComment) -> FeedbackComment:
116
+ self._comments[comment.id] = comment
117
+ return comment
118
+
119
+ async def list_comments(self, item_id: str) -> list[FeedbackComment]:
120
+ comments = [c for c in self._comments.values() if c.item_id == item_id]
121
+ comments.sort(key=lambda c: c.created_at)
122
+ return comments
123
+
124
+ async def get_stats(self) -> FeedbackStats:
125
+ items = [i for i in self._items.values() if i.merged_into is None]
126
+ by_category: dict[str, int] = {}
127
+ by_status: dict[str, int] = {}
128
+ by_priority: dict[str, int] = {}
129
+ resolution_hours: list[float] = []
130
+
131
+ for item in items:
132
+ by_category[item.category.value] = by_category.get(item.category.value, 0) + 1
133
+ by_status[item.status.value] = by_status.get(item.status.value, 0) + 1
134
+ by_priority[item.priority.value] = by_priority.get(item.priority.value, 0) + 1
135
+ if item.resolved_at is not None:
136
+ delta = item.resolved_at - item.created_at
137
+ resolution_hours.append(delta.total_seconds() / 3600)
138
+
139
+ avg_hours = sum(resolution_hours) / len(resolution_hours) if resolution_hours else None
140
+
141
+ return FeedbackStats(
142
+ total=len(items),
143
+ by_category=by_category,
144
+ by_status=by_status,
145
+ by_priority=by_priority,
146
+ avg_resolution_hours=avg_hours,
147
+ )
148
+
149
+ async def list_all_items(self) -> list[FeedbackItem]:
150
+ return list(self._items.values())
151
+
152
+ @staticmethod
153
+ def _apply_filter(items: list[FeedbackItem], filter_: FeedbackFilter) -> list[FeedbackItem]:
154
+ if filter_.category is not None:
155
+ items = [i for i in items if i.category == filter_.category]
156
+ if filter_.status is not None:
157
+ items = [i for i in items if i.status == filter_.status]
158
+ if filter_.priority is not None:
159
+ items = [i for i in items if i.priority == filter_.priority]
160
+ if filter_.tag is not None:
161
+ items = [i for i in items if filter_.tag in i.tags]
162
+ if filter_.search_query:
163
+ query = filter_.search_query.lower()
164
+ items = [i for i in items if query in i.title.lower() or query in i.description.lower()]
165
+ return items
166
+
167
+ @staticmethod
168
+ def _apply_sort(items: list[FeedbackItem], sort_by: FeedbackSortBy) -> list[FeedbackItem]:
169
+ match sort_by:
170
+ case FeedbackSortBy.NEWEST:
171
+ return sorted(items, key=lambda i: i.created_at, reverse=True)
172
+ case FeedbackSortBy.OLDEST:
173
+ return sorted(items, key=lambda i: i.created_at)
174
+ case FeedbackSortBy.VOTES:
175
+ return sorted(items, key=lambda i: i.vote_count, reverse=True)
176
+ case _:
177
+ return items
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: msaas-feedback
3
+ Version: 0.1.0
4
+ Summary: User feedback and feature request management for SaaS
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: msaas-api-core
7
+ Requires-Dist: msaas-errors
8
+ Requires-Dist: pydantic>=2.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: fastapi>=0.110.0; extra == 'dev'
11
+ Requires-Dist: httpx>=0.27; extra == 'dev'
12
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Requires-Dist: ruff>=0.8; extra == 'dev'
15
+ Provides-Extra: fastapi
16
+ Requires-Dist: fastapi>=0.110.0; extra == 'fastapi'
@@ -0,0 +1,10 @@
1
+ feedback/__init__.py,sha256=4_Kq2VVD6L91dRv58OY9OQzCri772qtSMQziBqnfLHE,1052
2
+ feedback/analysis.py,sha256=s8w6O1bmVJuLwBUmDSQ0o09aVZ561NgdM3ddhZ-Bq14,2305
3
+ feedback/config.py,sha256=aVqsekzOXpHHG7JkIIZCjkbmvLB-Ba9t4VvpupbC_BY,1871
4
+ feedback/models.py,sha256=Sc-PqHXU6nqW6qAsz2-eBPEddE-eIVNePvjInWBCfqE,3163
5
+ feedback/router.py,sha256=Mp2i1Z90iwsItoJq1UeHLymfqra2UiThQZ-_F2syyYQ,7291
6
+ feedback/service.py,sha256=YkY0UsPjXY8ZUfXkEdAm6Ai-X8FK1tTxPgzmRGSjSdA,8999
7
+ feedback/store.py,sha256=WBXR-zSuFqF-I1m87lnhKVhgKgjthh-cZc1nt4qkdHM,6348
8
+ msaas_feedback-0.1.0.dist-info/METADATA,sha256=onTA3TR1c3UQjmEKZW4ZgVG5PMm7DNvQRZB7_8_6hz4,555
9
+ msaas_feedback-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ msaas_feedback-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any