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 +43 -0
- feedback/analysis.py +70 -0
- feedback/config.py +71 -0
- feedback/models.py +124 -0
- feedback/router.py +218 -0
- feedback/service.py +260 -0
- feedback/store.py +177 -0
- msaas_feedback-0.1.0.dist-info/METADATA +16 -0
- msaas_feedback-0.1.0.dist-info/RECORD +10 -0
- msaas_feedback-0.1.0.dist-info/WHEEL +4 -0
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,,
|