Skip to content

Commit 6d0ca19

Browse files
committed
Fix merge conflict
2 parents 25a254b + f2ba56c commit 6d0ca19

14 files changed

Lines changed: 258 additions & 97 deletions

File tree

app/backend/src/couchers/email/locales/ru.json

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,23 @@
33
"completed": {
44
"recovery_instructions_days_one": "Если вы передумаете, <strong>у вас есть {{count}} день для восстановления вашей учётной записи</strong>, перейдя по следующей ссылке:",
55
"recovery_instructions_days_few": "Если вы передумаете, <strong>у вас есть {{count}} дня для восстановления вашей учётной записи</strong>, перейдя по следующей ссылке:",
6-
"recovery_instructions_days_many": "Если вы передумаете, <strong>у вас есть {{count}} дней для восстановления вашей учётной записи</strong>, перейдя по следующей ссылке:"
6+
"recovery_instructions_days_many": "Если вы передумаете, <strong>у вас есть {{count}} дней для восстановления вашей учётной записи</strong>, перейдя по следующей ссылке:",
7+
"subject": "Ваш аккаунт был удалён",
8+
"purpose": "Вы успешно удалили свою учётную запись Couchers.org.",
9+
"farewell": "Нам грустно, что ты уходишь, и мы желаем тебе всего наилучшего в твоих будущих путешествиях. Ты всегда можешь вернуться на платформу Couchers.org :)",
10+
"recover_action": "Восстановить аккаунт"
11+
},
12+
"started": {
13+
"subject": "Подтвердите удаление вашего аккаунта",
14+
"purpose": "Вы запросили удаление вашей учётной записи Couchers.org. Чтобы завершить этот процесс, пожалуйста, перейдите по следующей ссылке:",
15+
"confirm_action": "Подтвердить удаление аккаунта"
16+
},
17+
"recovered": {
18+
"subject": "Ваш аккаунт был восстановлен!",
19+
"confirmation": "Ваш аккаунт на Couchers.org был успешно восстановлен. Мы рады, что вы решили остаться!",
20+
"login_instructions": "Чтобы снова войти, нажмите на следующую ссылку:",
21+
"login_action": "Войти снова",
22+
"redelete_instructions": "Если вы передумаете, вы можете удалить свою учётную запись в любое время."
723
}
824
},
925
"references": {
@@ -20,7 +36,25 @@
2036
"thanks": "Спасибо!",
2137
"closing_lines": {
2238
"default": "С уважением,<br>Команда Couchers.org",
23-
"aapeli": "Аапели<br>Соучредитель Couchers.org<br>{{ profile_link }}"
39+
"aapeli": "Аапели<br>Соучредитель Couchers.org<br>{{ profile_link }}",
40+
"founders": "Аапели и Итси,<br>основатели Couchers.org",
41+
"emily": "Эмили из Couchers.org<br>{{ email_link }}<br>{{ profile_link }}"
42+
},
43+
"footer": {
44+
"received_because": "Вы получили это письмо, потому что зарегистрировались на Couchers.org.",
45+
"contact_support": "Вы можете связаться с нашей службой поддержки по адресу {{ email_link }}.",
46+
"donate_link": "Пожертвовать",
47+
"blog_link": "Блог",
48+
"nonprofit_note": "Couchers.org — это проект Couchers, Inc., некоммерческой организации США, освобождённой от налогов в соответствии с разделом 501(c)(3).",
49+
"security_email_note": "Это письмо системы безопасности, отписаться от него нельзя.",
50+
"notification_settings_link": "Управление настройками уведомлений"
2451
}
52+
},
53+
"calendar_events": {
54+
"title_cancelled": "Отменено: {{ title }}"
55+
},
56+
"plaintext_formats": {
57+
"action": "{{text}}: {{url}}",
58+
"user": "{{name}}, {{age}}, {{city}}"
2559
}
2660
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from typing import Any
2+
3+
from sqlalchemy import select
4+
from sqlalchemy.orm import aliased
5+
from sqlalchemy.sql import Select, exists, func, or_
6+
7+
from couchers.models import HostRequest, Reference, ReferenceType
8+
9+
10+
def where_references_not_hidden_by_reciprocity[T: tuple[Any, ...]](statement: Select[T]) -> Select[T]:
11+
"""
12+
Filters out references that are still hidden by the reciprocal-reference rule.
13+
14+
A host/surf reference stays hidden until either the recipient has written their
15+
reciprocal reference or the 2-week window to write one has closed; friend
16+
references are always visible.
17+
18+
Apply this to any query that selects from Reference. Both the reference list
19+
(ListReferences) and the reference count (get_num_references) must use it,
20+
otherwise the count includes references the list hides, leaking the existence
21+
of a still-hidden reference.
22+
"""
23+
other_reference = aliased(Reference)
24+
reciprocal_written = exists(
25+
select(other_reference.id)
26+
.where(other_reference.host_request_id == Reference.host_request_id)
27+
.where(other_reference.from_user_id == Reference.to_user_id)
28+
.where(other_reference.reference_type != ReferenceType.friend)
29+
)
30+
window_closed = exists(
31+
select(HostRequest.conversation_id)
32+
.where(HostRequest.conversation_id == Reference.host_request_id)
33+
.where(HostRequest.end_time_to_write_reference < func.now())
34+
)
35+
return statement.where(
36+
or_(
37+
Reference.reference_type == ReferenceType.friend,
38+
reciprocal_written,
39+
window_closed,
40+
)
41+
)

app/backend/src/couchers/i18n/context.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@
2121
localize_list,
2222
localize_time,
2323
localize_timezone,
24-
try_localize_language_name_from_iso639,
25-
try_localize_region_name_from_iso3166,
2624
)
2725
from couchers.models.users import User
2826
from couchers.utils import to_timezone
@@ -67,12 +65,6 @@ def __setattr__(self, name: str, value: Any) -> None:
6765
def localized_timezone(self) -> str:
6866
return localize_timezone(self.timezone, self.babel_locale)
6967

70-
def try_localize_language_name_from_iso639(self, code: str, standalone: bool = False) -> str | None:
71-
return try_localize_language_name_from_iso639(code, self.babel_locale, standalone=standalone)
72-
73-
def try_localize_region_name_from_iso3166(self, code: str) -> str | None:
74-
return try_localize_region_name_from_iso3166(code, self.babel_locale)
75-
7668
def localize_string(
7769
self, key: str, *, i18next: I18Next | None = None, substitutions: SubstitutionDict | None = None
7870
) -> str:

app/backend/src/couchers/i18n/locales/ru.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,12 @@
231231
"public_trip_in_past": "Вы не можете редактировать уже завершившуюся публичную поездку.",
232232
"public_trip_not_active": "Для этой публичной поездки больше не принимаются предложения.",
233233
"public_trips_not_enabled": "Публичные поездки недоступны в этом сообществе.",
234-
"sms_disabled": "В настоящее время SMS верификация отключена."
234+
"sms_disabled": "В настоящее время SMS верификация отключена.",
235+
"incomplete_profile_post_comment": "Прежде чем оставить комментарий, вам необходимо заполнить свой профиль.",
236+
"public_trip_not_found": "Не удалось найти эту публичную поездку.",
237+
"public_trip_user_mismatch": "Получатель запроса не совпадает с путешественником указанным в публичной поездке.",
238+
"duplicate_host_request_for_trip": "Вы уже предложили принять гостей для этой поездки.",
239+
"public_trip_same_gender_only": "Эта поездка открыта для предложений только от пользователей того же пола."
235240
},
236241
"quick_links": {
237242
"topic_action": "Вы были отписаны от уведомлений по электронной почте этого типа.",

app/backend/src/couchers/servicers/api.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from couchers.crypto import b64encode, generate_hash_signature, random_hex
1919
from couchers.event_log import log_event
2020
from couchers.helpers.completed_profile import has_completed_profile
21+
from couchers.helpers.references import where_references_not_hidden_by_reciprocity
2122
from couchers.helpers.strong_verification import get_strong_verification_fields
2223
from couchers.materialized_views import LiteUser, UserResponseRate
2324
from couchers.models import (
@@ -1025,6 +1026,8 @@ def get_num_references(session: Session, context: CouchersContext, user_ids: Ite
10251026
query = where_moderated_content_visible(
10261027
select(Reference.to_user_id, func.count(Reference.id)), context, Reference, is_list_operation=True
10271028
)
1029+
# exclude references still hidden by the reciprocal-reference rule, matching ListReferences
1030+
query = where_references_not_hidden_by_reciprocity(query)
10281031
query = (
10291032
query.where(Reference.to_user_id.in_(user_ids))
10301033
.join(User, User.id == Reference.from_user_id)

app/backend/src/couchers/servicers/communities.py

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from google.protobuf import empty_pb2
77
from sqlalchemy import select
88
from sqlalchemy.orm import Session, selectinload
9-
from sqlalchemy.sql import delete, func, or_
9+
from sqlalchemy.sql import and_, delete, func, or_
1010

1111
from couchers.constants import COMMUNITIES_SEARCH_FUZZY_SIMILARITY_THRESHOLD
1212
from couchers.context import CouchersContext
@@ -570,27 +570,34 @@ def ListUserCommunities(
570570
self, request: communities_pb2.ListUserCommunitiesReq, context: CouchersContext, session: Session
571571
) -> communities_pb2.ListUserCommunitiesRes:
572572
page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
573-
next_node_id = int(request.page_token) if request.page_token else 0
574573
user_id = request.user_id or context.user_id
575-
nodes = (
576-
session.execute(
577-
select(Node)
578-
.join(Cluster, Cluster.parent_node_id == Node.id)
579-
.join(ClusterSubscription, ClusterSubscription.cluster_id == Cluster.id)
580-
.where(ClusterSubscription.user_id == user_id)
581-
.where(Cluster.is_official_cluster)
582-
.where(Node.id >= next_node_id)
583-
.order_by(Node.id)
584-
.limit(page_size + 1)
585-
.options(selectinload(Node.official_cluster))
574+
# most specific communities first: node_type desc (sublocality -> world), then id asc
575+
statement = (
576+
select(Node)
577+
.join(Cluster, Cluster.parent_node_id == Node.id)
578+
.join(ClusterSubscription, ClusterSubscription.cluster_id == Cluster.id)
579+
.where(ClusterSubscription.user_id == user_id)
580+
.where(Cluster.is_official_cluster)
581+
.order_by(Node.node_type.desc(), Node.id)
582+
.limit(page_size + 1)
583+
.options(selectinload(Node.official_cluster))
584+
)
585+
if request.page_token:
586+
node_type_ordinal, next_node_id = (int(v) for v in decrypt_page_token(request.page_token).split(","))
587+
next_node_type = NodeType(node_type_ordinal)
588+
statement = statement.where(
589+
or_(
590+
Node.node_type < next_node_type,
591+
and_(Node.node_type == next_node_type, Node.id >= next_node_id),
592+
)
586593
)
587-
.scalars()
588-
.all()
589-
)
594+
nodes = session.execute(statement).scalars().all()
590595

591596
return communities_pb2.ListUserCommunitiesRes(
592597
communities=communities_to_pb(session, nodes[:page_size], context),
593-
next_page_token=str(nodes[-1].id) if len(nodes) > page_size else None,
598+
next_page_token=encrypt_page_token(f"{nodes[-1].node_type.value},{nodes[-1].id}")
599+
if len(nodes) > page_size
600+
else None,
594601
)
595602

596603
def ListAllCommunities(

app/backend/src/couchers/servicers/references.py

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@
1111
from google.protobuf import empty_pb2
1212
from sqlalchemy import select
1313
from sqlalchemy.orm import Session, aliased
14-
from sqlalchemy.sql import and_, func, literal, or_, union_all
14+
from sqlalchemy.sql import and_, literal, or_, union_all
1515

1616
from couchers.context import CouchersContext, make_notification_user_context
1717
from couchers.db import are_friends
1818
from couchers.event_log import log_event
19+
from couchers.helpers.references import where_references_not_hidden_by_reciprocity
1920
from couchers.materialized_views import LiteUser
2021
from couchers.models import HostRequest, ModerationObjectType, Reference, ReferenceType, User
2122
from couchers.models.notifications import NotificationTopicAction
@@ -208,33 +209,13 @@ def ListReferences(
208209
if next_reference_id:
209210
statement = statement.where(Reference.id <= next_reference_id)
210211

211-
# Reference visibility logic:
212-
# A reference is visible if any of the following apply:
212+
# Reference visibility logic (a reference is visible if any of the following apply):
213213
# 1. It is a friend reference
214214
# 2. Both references have been written
215215
# 3. It has been over 2 weeks since the host request ended
216-
217-
# we get the matching other references through this subquery
218-
sub = select(Reference.id.label("sub_id"), Reference.host_request_id).where(
219-
Reference.reference_type != ReferenceType.friend
220-
)
221-
if request.from_user_id:
222-
sub = sub.where(Reference.to_user_id == request.from_user_id)
223-
if request.to_user_id:
224-
sub = sub.where(Reference.from_user_id == request.to_user_id)
225-
226-
query = sub.subquery()
227-
statement = (
228-
statement.outerjoin(query, query.c.host_request_id == Reference.host_request_id)
229-
.outerjoin(HostRequest, HostRequest.conversation_id == Reference.host_request_id)
230-
.where(
231-
or_(
232-
Reference.reference_type == ReferenceType.friend,
233-
query.c.sub_id != None,
234-
HostRequest.end_time_to_write_reference < func.now(),
235-
)
236-
)
237-
)
216+
# This must stay in sync with the reference count (get_num_references); both use the
217+
# shared where_references_not_hidden_by_reciprocity() helper.
218+
statement = where_references_not_hidden_by_reciprocity(statement)
238219

239220
statement = statement.order_by(Reference.id.desc()).limit(page_size + 1)
240221
references = session.execute(statement).scalars().all()

app/backend/src/couchers/servicers/resources.py

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
1+
import functools
12
import logging
23

4+
import babel
35
from google.protobuf import empty_pb2
46
from sqlalchemy.orm import Session
57

68
from couchers.context import CouchersContext
9+
from couchers.i18n.localize import try_localize_language_name_from_iso639, try_localize_region_name_from_iso3166
710
from couchers.proto import resources_pb2, resources_pb2_grpc
8-
from couchers.resources import (
9-
get_badge_dict,
10-
get_icon,
11-
get_language_dict,
12-
get_region_dict,
13-
get_terms_of_service,
14-
)
11+
from couchers.resources import get_badge_dict, get_icon, get_language_dict, get_region_dict, get_terms_of_service
1512

1613
logger = logging.getLogger(__name__)
1714

@@ -23,6 +20,36 @@
2320
]
2421

2522

23+
# These responses are static per locale, and localizing ~300 names costs tens of ms of CPU (mostly
24+
# babel.Locale.parse), so cache the built protos. Sharing one message across requests is safe as
25+
# long as nothing mutates responses after return (the tracing interceptor deepcopies before
26+
# sanitizing).
27+
@functools.lru_cache
28+
def _get_regions_res(locale: babel.Locale) -> resources_pb2.GetRegionsRes:
29+
return resources_pb2.GetRegionsRes(
30+
regions=[
31+
resources_pb2.Region(
32+
alpha3=alpha3,
33+
name=try_localize_region_name_from_iso3166(alpha3, locale) or english_name,
34+
)
35+
for alpha3, english_name in sorted(get_region_dict().items())
36+
]
37+
)
38+
39+
40+
@functools.lru_cache
41+
def _get_languages_res(locale: babel.Locale) -> resources_pb2.GetLanguagesRes:
42+
return resources_pb2.GetLanguagesRes(
43+
languages=[
44+
resources_pb2.Language(
45+
code=code,
46+
name=try_localize_language_name_from_iso639(code, locale, standalone=True) or english_name,
47+
)
48+
for code, english_name in sorted(get_language_dict().items())
49+
]
50+
)
51+
52+
2653
class Resources(resources_pb2_grpc.ResourcesServicer):
2754
def GetTermsOfService(
2855
self, request: empty_pb2.Empty, context: CouchersContext, session: Session
@@ -46,29 +73,12 @@ def GetCommunityGuidelines(
4673
def GetRegions(
4774
self, request: empty_pb2.Empty, context: CouchersContext, session: Session
4875
) -> resources_pb2.GetRegionsRes:
49-
return resources_pb2.GetRegionsRes(
50-
regions=[
51-
resources_pb2.Region(
52-
alpha3=alpha3,
53-
name=context.localization.try_localize_region_name_from_iso3166(alpha3) or english_name,
54-
)
55-
for alpha3, english_name in sorted(get_region_dict().items())
56-
]
57-
)
76+
return _get_regions_res(context.localization.babel_locale)
5877

5978
def GetLanguages(
6079
self, request: empty_pb2.Empty, context: CouchersContext, session: Session
6180
) -> resources_pb2.GetLanguagesRes:
62-
return resources_pb2.GetLanguagesRes(
63-
languages=[
64-
resources_pb2.Language(
65-
code=code,
66-
name=context.localization.try_localize_language_name_from_iso639(code, standalone=True)
67-
or english_name,
68-
)
69-
for code, english_name in sorted(get_language_dict().items())
70-
]
71-
)
81+
return _get_languages_res(context.localization.babel_locale)
7282

7383
def GetBadges(
7484
self, request: empty_pb2.Empty, context: CouchersContext, session: Session

0 commit comments

Comments
 (0)