Skip to content

Commit ddbb612

Browse files
committed
(PC-32268)[API] feat: Return individual or collective statistics only if offerer is concerned
1 parent 8e347d1 commit ddbb612

File tree

12 files changed

+231
-32
lines changed

12 files changed

+231
-32
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1+
from .yearly_revenue import YearlyAggregatedCollectiveRevenueQuery
2+
from .yearly_revenue import YearlyAggregatedIndividualRevenueQuery
13
from .yearly_revenue import YearlyAggregatedRevenueModel
24
from .yearly_revenue import YearlyAggregatedRevenueQuery

api/src/pcapi/connectors/clickhouse/queries/yearly_revenue.py

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,30 @@
77
from pcapi.routes.serialization.offers_serialize import to_camel
88

99

10-
class Revenue(pydantic_v1.BaseModel):
11-
total: Decimal
10+
class IndividualRevenue(pydantic_v1.BaseModel):
1211
individual: Decimal
12+
13+
class Config:
14+
extra = "forbid"
15+
16+
17+
class CollectiveRevenue(pydantic_v1.BaseModel):
1318
collective: Decimal
1419

1520
class Config:
1621
extra = "forbid"
1722

1823

24+
class CollectiveAndIndividualRevenue(IndividualRevenue, CollectiveRevenue):
25+
total: Decimal
26+
27+
class Config:
28+
extra = "forbid"
29+
30+
1931
class AggregatedRevenue(pydantic_v1.BaseModel):
20-
revenue: Revenue
21-
expected_revenue: Revenue
32+
revenue: CollectiveAndIndividualRevenue | CollectiveRevenue | IndividualRevenue
33+
expected_revenue: CollectiveAndIndividualRevenue | CollectiveRevenue | IndividualRevenue
2234

2335
class Config:
2436
extra = "forbid"
@@ -33,7 +45,7 @@ class Config:
3345
alias_generator = to_camel
3446

3547

36-
class YearlyAggregatedRevenueQuery(BaseQuery[YearlyAggregatedRevenueModel]):
48+
class YearlyAggregatedRevenueQueryMixin:
3749
def _format_result(self, results: list) -> dict:
3850
return {
3951
"incomeByYear": {
@@ -45,6 +57,54 @@ def _format_result(self, results: list) -> dict:
4557
}
4658
}
4759

60+
@property
61+
def model(self) -> type[YearlyAggregatedRevenueModel]:
62+
return YearlyAggregatedRevenueModel
63+
64+
65+
class YearlyAggregatedCollectiveRevenueQuery(
66+
YearlyAggregatedRevenueQueryMixin, BaseQuery[YearlyAggregatedRevenueModel]
67+
):
68+
@property
69+
def raw_query(self) -> str:
70+
return """
71+
SELECT
72+
EXTRACT(YEAR FROM creation_year) AS year,
73+
toJSONString(map(
74+
'collective', ROUND(SUM(revenue),2),
75+
) as revenue,
76+
toJSONString(map(
77+
'collective', ROUND(SUM(expected_revenue),2),
78+
) as expected_revenue
79+
FROM analytics.yearly_aggregated_venue_collective_revenue
80+
WHERE "venue_id" in %s
81+
GROUP BY year
82+
ORDER BY year
83+
"""
84+
85+
86+
class YearlyAggregatedIndividualRevenueQuery(
87+
YearlyAggregatedRevenueQueryMixin, BaseQuery[YearlyAggregatedRevenueModel]
88+
):
89+
@property
90+
def raw_query(self) -> str:
91+
return """
92+
SELECT
93+
EXTRACT(YEAR FROM creation_year) AS year,
94+
toJSONString(map(
95+
'individual', ROUND(SUM(revenue),2),
96+
) as revenue,
97+
toJSONString(map(
98+
'individual', ROUND(SUM(expected_revenue),2),
99+
) as expected_revenue
100+
FROM analytics.yearly_aggregated_venue_individual_revenue
101+
WHERE "venue_id" in %s
102+
GROUP BY year
103+
ORDER BY year
104+
"""
105+
106+
107+
class YearlyAggregatedRevenueQuery(YearlyAggregatedRevenueQueryMixin, BaseQuery[YearlyAggregatedRevenueModel]):
48108
@property
49109
def raw_query(self) -> str:
50110
return """
@@ -65,7 +125,3 @@ def raw_query(self) -> str:
65125
GROUP BY year
66126
ORDER BY year
67127
"""
68-
69-
@property
70-
def model(self) -> type[YearlyAggregatedRevenueModel]:
71-
return YearlyAggregatedRevenueModel

api/src/pcapi/core/offers/repository.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,3 +1326,14 @@ def merge_products(to_keep: models.Product, to_delete: models.Product) -> models
13261326
db.session.delete(to_delete)
13271327

13281328
return to_keep
1329+
1330+
1331+
def venues_have_individual_and_collective_offers(venue_ids: list[int]) -> tuple[bool, bool]:
1332+
return (
1333+
db.session.query(offers_model.Offer.query.filter(offers_model.Offer.venueId.in_(venue_ids)).exists()).scalar(),
1334+
db.session.query(
1335+
educational_models.CollectiveOffer.query.filter(
1336+
educational_models.CollectiveOffer.venueId.in_(venue_ids)
1337+
).exists()
1338+
).scalar(),
1339+
)

api/src/pcapi/core/users/repository.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def has_access(user: models.User, offerer_id: int) -> bool:
8989

9090

9191
def has_access_to_venues(user: models.User, venue_ids: list[int]) -> bool:
92-
"""Return whether the user has access to the requested venues' data."""
92+
"""Return whether the user has access to all the requested venues' data."""
9393
query = offerers_models.UserOfferer.query
9494
query = query.options(
9595
joinedload(offerers_models.UserOfferer).load_only(
@@ -107,10 +107,9 @@ def has_access_to_venues(user: models.User, venue_ids: list[int]) -> bool:
107107
offerers_models.UserOfferer.isValidated,
108108
offerers_models.Venue.id.in_(venue_ids),
109109
]
110+
have_access_to_all_venues = query.filter(*filters).count() == len(venue_ids)
110111

111-
query = db.session.query(query.filter(*filters).exists()).scalar()
112-
113-
return query
112+
return have_access_to_all_venues
114113

115114

116115
def get_newly_eligible_age_18_users(since: date) -> list[models.User]:

api/src/pcapi/routes/pro/statistics.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from flask_login import login_required
33

44
from pcapi.connectors.clickhouse import queries as clickhouse_queries
5+
from pcapi.core.offers.repository import venues_have_individual_and_collective_offers
56
from pcapi.models.api_errors import ApiErrors
67
from pcapi.routes.apis import private_api
78
from pcapi.routes.serialization.statistics_serialize import StatisticsModel
@@ -26,5 +27,11 @@ def get_statistics(query: StatisticsQueryModel) -> StatisticsModel:
2627
status_code=422,
2728
)
2829
check_user_has_access_to_venues(current_user, venue_ids)
29-
result = clickhouse_queries.YearlyAggregatedRevenueQuery().execute(tuple(venue_ids))
30+
venues_have_individual, venues_have_collective = venues_have_individual_and_collective_offers(venue_ids)
31+
if not venues_have_individual:
32+
result = clickhouse_queries.YearlyAggregatedCollectiveRevenueQuery().execute(tuple(venue_ids))
33+
elif not venues_have_collective:
34+
result = clickhouse_queries.YearlyAggregatedIndividualRevenueQuery().execute(tuple(venue_ids))
35+
else:
36+
result = clickhouse_queries.YearlyAggregatedRevenueQuery().execute(tuple(venue_ids))
3037
return StatisticsModel.from_query(income_by_year=result.income_by_year)

api/tests/connectors/clickhouse/fixtures.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,27 @@ def __init__(
1414
collective: Decimal = Decimal("12.12"),
1515
expected_individual: Decimal = Decimal("13.12"),
1616
expected_collective: Decimal = Decimal("13.12"),
17+
only_collective: bool = False,
18+
only_individual: bool = False,
1719
) -> object:
1820
self.year = year
19-
self.revenue = json.dumps(
20-
{"individual": str(individual), "collective": str(collective), "total": str(individual + collective)}
21-
)
22-
self.expected_revenue = json.dumps(
23-
{
24-
"individual": str(expected_individual),
25-
"collective": str(expected_collective),
26-
"total": str(expected_individual + expected_collective),
27-
}
28-
)
21+
if only_collective:
22+
self.revenue = json.dumps({"collective": str(collective)})
23+
self.expected_revenue = json.dumps({"collective": str(expected_collective)})
24+
elif only_individual:
25+
self.revenue = json.dumps({"individual": str(individual)})
26+
self.expected_revenue = json.dumps({"individual": str(expected_individual)})
27+
else:
28+
self.revenue = json.dumps(
29+
{"individual": str(individual), "collective": str(collective), "total": str(individual + collective)}
30+
)
31+
self.expected_revenue = json.dumps(
32+
{
33+
"individual": str(expected_individual),
34+
"collective": str(expected_collective),
35+
"total": str(expected_individual + expected_collective),
36+
}
37+
)
2938

3039

3140
YEARLY_AGGREGATED_VENUE_REVENUE = [MockYearlyAggregatedRevenueQueryResult()]
@@ -35,3 +44,15 @@ def __init__(
3544
2022, Decimal("22.12"), Decimal("22.12"), Decimal("22.12"), Decimal("22.12")
3645
),
3746
]
47+
YEARLY_AGGREGATED_VENUE_REVENUE_MULTIPLE_YEARS_ONLY_COLLECTIVE = [
48+
MockYearlyAggregatedRevenueQueryResult(only_collective=True),
49+
MockYearlyAggregatedRevenueQueryResult(
50+
2022, Decimal("22.12"), Decimal("22.12"), Decimal("22.12"), Decimal("22.12"), only_collective=True
51+
),
52+
]
53+
YEARLY_AGGREGATED_VENUE_REVENUE_MULTIPLE_YEARS_ONLY_INDIVIDUAL = [
54+
MockYearlyAggregatedRevenueQueryResult(only_individual=True),
55+
MockYearlyAggregatedRevenueQueryResult(
56+
2022, Decimal("22.12"), Decimal("22.12"), Decimal("22.12"), Decimal("22.12"), only_individual=True
57+
),
58+
]

api/tests/routes/pro/get_statistics_test.py

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import pytest
44

55
from pcapi.core import testing
6+
import pcapi.core.educational.factories as educational_factories
67
import pcapi.core.offerers.factories as offerers_factories
8+
import pcapi.core.offers.factories as offers_factories
79
import pcapi.core.users.factories as users_factories
810

911
from tests.connectors.clickhouse import fixtures
@@ -18,10 +20,14 @@ def test_get_statistics_from_one_venue(self, run_query, client):
1820
offerers_factories.UserOffererFactory(user=user, offerer=offerer)
1921
venue = offerers_factories.VenueFactory(managingOfferer=offerer)
2022
venue_id = venue.id
23+
educational_factories.CollectiveOfferFactory(venue=venue)
24+
offers_factories.OfferFactory(venue=venue)
2125

2226
test_client = client.with_session_auth(email=user.email)
2327
num_queries = testing.AUTHENTICATION_QUERIES
2428
num_queries += 1 # select Offerer
29+
num_queries += 1 # select Offer
30+
num_queries += 1 # select CollectiveOffer
2531
with testing.assert_num_queries(num_queries):
2632
run_query.return_value = fixtures.YEARLY_AGGREGATED_VENUE_REVENUE
2733
response = test_client.get(f"/get-statistics/?venue_ids={venue_id}")
@@ -44,10 +50,14 @@ def test_get_statistics_from_multiple_venues(self, run_query, client):
4450
venue2 = offerers_factories.VenueFactory(managingOfferer=offerer)
4551
venue_id = venue.id
4652
venue2_id = venue2.id
53+
educational_factories.CollectiveOfferFactory(venue=venue)
54+
offers_factories.OfferFactory(venue=venue)
4755

4856
test_client = client.with_session_auth(email=user.email)
4957
num_queries = testing.AUTHENTICATION_QUERIES
5058
num_queries += 1 # select Offerer
59+
num_queries += 1 # select Offer
60+
num_queries += 1 # select CollectiveOffer
5161
with testing.assert_num_queries(num_queries):
5262
run_query.return_value = fixtures.YEARLY_AGGREGATED_VENUE_REVENUE
5363
response = test_client.get(f"/get-statistics/?venue_ids={venue_id}&venue_ids={venue2_id}")
@@ -70,10 +80,14 @@ def test_get_statistics_multiple_years(self, run_query, client):
7080
venue2 = offerers_factories.VenueFactory(managingOfferer=offerer)
7181
venue_id = venue.id
7282
venue2_id = venue2.id
83+
educational_factories.CollectiveOfferFactory(venue=venue)
84+
offers_factories.OfferFactory(venue=venue)
7385

7486
test_client = client.with_session_auth(email=user.email)
7587
num_queries = testing.AUTHENTICATION_QUERIES
7688
num_queries += 1 # select Offerer
89+
num_queries += 1 # select Offer
90+
num_queries += 1 # select CollectiveOffer
7791
with testing.assert_num_queries(num_queries):
7892
run_query.return_value = fixtures.YEARLY_AGGREGATED_VENUE_REVENUE_MULTIPLE_YEARS
7993
response = test_client.get(f"/get-statistics/?venue_ids={venue_id}&venue_ids={venue2_id}")
@@ -92,16 +106,84 @@ def test_get_statistics_multiple_years(self, run_query, client):
92106
}
93107
}
94108

109+
@patch("pcapi.connectors.clickhouse.testing_backend.TestingBackend.run_query")
110+
def test_get_statistics_only_collective(self, run_query, client):
111+
user = users_factories.UserFactory()
112+
offerer = offerers_factories.OffererFactory()
113+
offerers_factories.UserOffererFactory(user=user, offerer=offerer)
114+
venue = offerers_factories.VenueFactory(managingOfferer=offerer)
115+
venue_id = venue.id
116+
educational_factories.CollectiveOfferFactory(venue=venue)
117+
118+
test_client = client.with_session_auth(email=user.email)
119+
num_queries = testing.AUTHENTICATION_QUERIES
120+
num_queries += 1 # select Offerer
121+
num_queries += 1 # select Offer
122+
num_queries += 1 # select CollectiveOffer
123+
with testing.assert_num_queries(num_queries):
124+
run_query.return_value = fixtures.YEARLY_AGGREGATED_VENUE_REVENUE_MULTIPLE_YEARS_ONLY_COLLECTIVE
125+
response = test_client.get(f"/get-statistics/?venue_ids={venue_id}")
126+
assert response.status_code == 200
127+
assert response.json == {
128+
"incomeByYear": {
129+
"2022": {
130+
"expectedRevenue": {"collective": 22.12},
131+
"revenue": {"collective": 22.12},
132+
},
133+
"2023": {},
134+
"2024": {
135+
"expectedRevenue": {"collective": 13.12},
136+
"revenue": {"collective": 12.12},
137+
},
138+
}
139+
}
140+
141+
@patch("pcapi.connectors.clickhouse.testing_backend.TestingBackend.run_query")
142+
def test_get_statistics_only_individual(self, run_query, client):
143+
user = users_factories.UserFactory()
144+
offerer = offerers_factories.OffererFactory()
145+
offerers_factories.UserOffererFactory(user=user, offerer=offerer)
146+
venue = offerers_factories.VenueFactory(managingOfferer=offerer)
147+
venue_id = venue.id
148+
offers_factories.OfferFactory(venue=venue)
149+
150+
test_client = client.with_session_auth(email=user.email)
151+
num_queries = testing.AUTHENTICATION_QUERIES
152+
num_queries += 1 # select Offerer
153+
num_queries += 1 # select Offer
154+
num_queries += 1 # select CollectiveOffer
155+
with testing.assert_num_queries(num_queries):
156+
run_query.return_value = fixtures.YEARLY_AGGREGATED_VENUE_REVENUE_MULTIPLE_YEARS_ONLY_INDIVIDUAL
157+
response = test_client.get(f"/get-statistics/?venue_ids={venue_id}")
158+
assert response.status_code == 200
159+
assert response.json == {
160+
"incomeByYear": {
161+
"2022": {
162+
"expectedRevenue": {"individual": 22.12},
163+
"revenue": {"individual": 22.12},
164+
},
165+
"2023": {},
166+
"2024": {
167+
"expectedRevenue": {"individual": 13.12},
168+
"revenue": {"individual": 12.12},
169+
},
170+
}
171+
}
172+
95173
def test_get_statistics_empty_result(self, client):
96174
user = users_factories.UserFactory()
97175
offerer = offerers_factories.OffererFactory()
98176
offerers_factories.UserOffererFactory(user=user, offerer=offerer)
99177
venue = offerers_factories.VenueFactory(managingOfferer=offerer)
100178
venue_id = venue.id
179+
educational_factories.CollectiveOfferFactory(venue=venue)
180+
offers_factories.OfferFactory(venue=venue)
101181

102182
test_client = client.with_session_auth(email=user.email)
103183
num_queries = testing.AUTHENTICATION_QUERIES
104184
num_queries += 1 # select Offerer
185+
num_queries += 1 # select Offer
186+
num_queries += 1 # select CollectiveOffer
105187
with testing.assert_num_queries(num_queries):
106188
response = test_client.get(f"/get-statistics/?venue_ids={venue_id}")
107189
assert response.status_code == 200
@@ -129,17 +211,18 @@ def test_get_statistics_with_no_venue_id_should_fail(self, client):
129211
class Returns403Test:
130212
def test_get_statistics_from_not_owned_venue_should_fail(self, client):
131213
user = users_factories.UserFactory()
132-
user2 = users_factories.UserFactory()
133214
offerer = offerers_factories.OffererFactory()
134-
offerers_factories.UserOffererFactory(user=user2, offerer=offerer)
215+
offerers_factories.UserOffererFactory(user=user, offerer=offerer)
135216
venue = offerers_factories.VenueFactory(managingOfferer=offerer)
217+
venue_not_belonging_to_user = offerers_factories.VenueFactory()
136218
venue_id = venue.id
219+
foreign_venue = venue_not_belonging_to_user.id
137220

138221
test_client = client.with_session_auth(email=user.email)
139222
num_queries = testing.AUTHENTICATION_QUERIES
140223
num_queries += 1 # select Offerer
141224
with testing.assert_num_queries(num_queries):
142-
response = test_client.get(f"/get-statistics/?venue_ids={venue_id}")
225+
response = test_client.get(f"/get-statistics/?venue_ids={venue_id}&venue_ids={foreign_venue}")
143226
assert response.status_code == 403
144227
assert response.json["global"] == [
145228
"Vous n'avez pas les droits d'accès suffisants pour accéder à cette information."

0 commit comments

Comments
 (0)