diff --git a/tests/unit/manage/views/test_organizations.py b/tests/unit/manage/views/test_organizations.py index 35d6cba62337..f65bed0e08f4 100644 --- a/tests/unit/manage/views/test_organizations.py +++ b/tests/unit/manage/views/test_organizations.py @@ -1066,16 +1066,67 @@ def test_activate_subscription( self, db_request, organization, + monkeypatch, ): + organization_activate_billing_form_obj = pretend.stub() + organization_activate_billing_form_cls = pretend.call_recorder( + lambda *a, **kw: organization_activate_billing_form_obj + ) + monkeypatch.setattr( + org_views, + "OrganizationActivateBillingForm", + organization_activate_billing_form_cls, + ) + db_request.POST = MultiDict() + view = org_views.ManageOrganizationBillingViews(organization, db_request) - # We're not ready for companies to activate their own subscriptions yet. - with pytest.raises(HTTPNotFound): - assert view.activate_subscription() + result = view.activate_subscription() + + assert result == { + "organization": organization, + "form": organization_activate_billing_form_obj, + } + + @pytest.mark.usefixtures("_enable_organizations") + def test_post_activate_subscription_valid( + self, + db_request, + organization, + monkeypatch, + ): + db_request.method = "POST" + db_request.POST = MultiDict({"terms_of_service_agreement": "1"}) - # result = view.activate_subscription() + db_request.route_path = pretend.call_recorder( + lambda *a, **kw: "mock-billing-url" + ) + + view = org_views.ManageOrganizationBillingViews(organization, db_request) + + result = view.activate_subscription() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "mock-billing-url" + + @pytest.mark.usefixtures("_enable_organizations") + def test_post_activate_subscription_invalid( + self, + db_request, + organization, + monkeypatch, + ): + db_request.method = "POST" + db_request.POST = MultiDict() + + view = org_views.ManageOrganizationBillingViews(organization, db_request) - # assert result == {"organization": organization} + result = view.activate_subscription() + + assert result["organization"] == organization + assert result["form"].terms_of_service_agreement.errors == [ + "Terms of Service must be accepted." + ] @pytest.mark.usefixtures("_enable_organizations") def test_create_subscription( diff --git a/tests/unit/organizations/test_services.py b/tests/unit/organizations/test_services.py index 25feeed6c845..07e76386966a 100644 --- a/tests/unit/organizations/test_services.py +++ b/tests/unit/organizations/test_services.py @@ -27,6 +27,7 @@ OrganizationRoleType, OrganizationStripeCustomer, OrganizationStripeSubscription, + OrganizationTermsOfServiceAgreement, OrganizationType, Team, TeamProjectRole, @@ -634,6 +635,42 @@ def test_delete_organization_project(self, organization_service, db_request): .count() ) + def test_add_organization_terms_of_service_agreement( + self, organization_service, db_request + ): + organization = OrganizationFactory.create() + assert organization.terms_of_service_agreements == [] + organization_service.add_organization_terms_of_service_agreement( + organization.id + ) + assert ( + db_request.db.query(OrganizationTermsOfServiceAgreement) + .filter( + OrganizationTermsOfServiceAgreement.organization_id == organization.id, + OrganizationTermsOfServiceAgreement.agreed.isnot(None), + OrganizationTermsOfServiceAgreement.notified.is_(None), + ) + .count() + ) == 1 + + def test_add_organization_terms_of_service_agreement_notified( + self, organization_service, db_request + ): + organization = OrganizationFactory.create() + assert organization.terms_of_service_agreements == [] + organization_service.add_organization_terms_of_service_agreement( + organization.id, notified=True + ) + assert ( + db_request.db.query(OrganizationTermsOfServiceAgreement) + .filter( + OrganizationTermsOfServiceAgreement.organization_id == organization.id, + OrganizationTermsOfServiceAgreement.agreed.is_(None), + OrganizationTermsOfServiceAgreement.notified.isnot(None), + ) + .count() + ) == 1 + def test_add_organization_subscription(self, organization_service, db_request): organization = OrganizationFactory.create() stripe_customer = StripeCustomerFactory.create() diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 1e2004814d4a..6ac7358e3bd2 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -91,7 +91,7 @@ msgid "" msgstr "" #: warehouse/accounts/forms.py:410 warehouse/manage/forms.py:139 -#: warehouse/manage/forms.py:730 +#: warehouse/manage/forms.py:741 msgid "The name is too long. Choose a name with 100 characters or less." msgstr "" @@ -345,11 +345,11 @@ msgstr "" msgid "Banner Preview" msgstr "" -#: warehouse/manage/forms.py:408 +#: warehouse/manage/forms.py:419 msgid "Choose an organization account name with 50 characters or less." msgstr "" -#: warehouse/manage/forms.py:416 +#: warehouse/manage/forms.py:427 msgid "" "The organization account name is invalid. Organization account names must" " be composed of letters, numbers, dots, hyphens and underscores. And must" @@ -357,90 +357,90 @@ msgid "" "organization account name." msgstr "" -#: warehouse/manage/forms.py:439 +#: warehouse/manage/forms.py:450 msgid "" "This organization account name has already been used. Choose a different " "organization account name." msgstr "" -#: warehouse/manage/forms.py:454 +#: warehouse/manage/forms.py:465 msgid "" "You have already submitted an application for that name. Choose a " "different organization account name." msgstr "" -#: warehouse/manage/forms.py:490 +#: warehouse/manage/forms.py:501 msgid "Select project" msgstr "" -#: warehouse/manage/forms.py:495 warehouse/oidc/forms/_core.py:23 +#: warehouse/manage/forms.py:506 warehouse/oidc/forms/_core.py:23 #: warehouse/oidc/forms/gitlab.py:57 msgid "Specify project name" msgstr "" -#: warehouse/manage/forms.py:498 +#: warehouse/manage/forms.py:509 msgid "" "Start and end with a letter or numeral containing only ASCII numeric and " "'.', '_' and '-'." msgstr "" -#: warehouse/manage/forms.py:505 +#: warehouse/manage/forms.py:516 msgid "This project name has already been used. Choose a different project name." msgstr "" -#: warehouse/manage/forms.py:578 +#: warehouse/manage/forms.py:589 msgid "" "The organization name is too long. Choose a organization name with 100 " "characters or less." msgstr "" -#: warehouse/manage/forms.py:590 +#: warehouse/manage/forms.py:601 msgid "" "The organization URL is too long. Choose a organization URL with 400 " "characters or less." msgstr "" -#: warehouse/manage/forms.py:597 +#: warehouse/manage/forms.py:608 msgid "The organization URL must start with http:// or https://" msgstr "" -#: warehouse/manage/forms.py:608 +#: warehouse/manage/forms.py:619 msgid "" "The organization description is too long. Choose a organization " "description with 400 characters or less." msgstr "" -#: warehouse/manage/forms.py:643 +#: warehouse/manage/forms.py:654 msgid "You have already submitted the maximum number of " msgstr "" -#: warehouse/manage/forms.py:673 +#: warehouse/manage/forms.py:684 msgid "Choose a team name with 50 characters or less." msgstr "" -#: warehouse/manage/forms.py:679 +#: warehouse/manage/forms.py:690 msgid "" "The team name is invalid. Team names cannot start or end with a space, " "period, underscore, hyphen, or slash. Choose a different team name." msgstr "" -#: warehouse/manage/forms.py:707 +#: warehouse/manage/forms.py:718 msgid "This team name has already been used. Choose a different team name." msgstr "" -#: warehouse/manage/forms.py:726 +#: warehouse/manage/forms.py:737 msgid "Specify your alternate repository name" msgstr "" -#: warehouse/manage/forms.py:740 +#: warehouse/manage/forms.py:751 msgid "Specify your alternate repository URL" msgstr "" -#: warehouse/manage/forms.py:744 +#: warehouse/manage/forms.py:755 msgid "The URL is too long. Choose a URL with 400 characters or less." msgstr "" -#: warehouse/manage/forms.py:758 +#: warehouse/manage/forms.py:769 msgid "" "The description is too long. Choose a description with 400 characters or " "less." @@ -579,12 +579,12 @@ msgid "" msgstr "" #: warehouse/manage/views/__init__.py:2817 -#: warehouse/manage/views/organizations.py:878 +#: warehouse/manage/views/organizations.py:887 msgid "User '${username}' already has an active invite. Please try again later." msgstr "" #: warehouse/manage/views/__init__.py:2882 -#: warehouse/manage/views/organizations.py:943 +#: warehouse/manage/views/organizations.py:952 msgid "Invitation sent to '${username}'" msgstr "" @@ -597,30 +597,30 @@ msgid "Invitation already expired." msgstr "" #: warehouse/manage/views/__init__.py:2958 -#: warehouse/manage/views/organizations.py:1130 +#: warehouse/manage/views/organizations.py:1139 msgid "Invitation revoked from '${username}'." msgstr "" -#: warehouse/manage/views/organizations.py:854 +#: warehouse/manage/views/organizations.py:863 msgid "User '${username}' already has ${role_name} role for organization" msgstr "" -#: warehouse/manage/views/organizations.py:865 +#: warehouse/manage/views/organizations.py:874 msgid "" "User '${username}' does not have a verified primary email address and " "cannot be added as a ${role_name} for organization" msgstr "" -#: warehouse/manage/views/organizations.py:1026 -#: warehouse/manage/views/organizations.py:1068 +#: warehouse/manage/views/organizations.py:1035 +#: warehouse/manage/views/organizations.py:1077 msgid "Could not find organization invitation." msgstr "" -#: warehouse/manage/views/organizations.py:1036 +#: warehouse/manage/views/organizations.py:1045 msgid "Organization invitation could not be re-sent." msgstr "" -#: warehouse/manage/views/organizations.py:1083 +#: warehouse/manage/views/organizations.py:1092 msgid "Expired invitation for '${username}' deleted." msgstr "" @@ -1432,6 +1432,7 @@ msgstr "" #: warehouse/templates/manage/account/token.html:150 #: warehouse/templates/manage/account/totp-provision.html:69 #: warehouse/templates/manage/account/webauthn-provision.html:44 +#: warehouse/templates/manage/organization/activate_subscription.html:34 #: warehouse/templates/manage/organization/projects.html:128 #: warehouse/templates/manage/organization/projects.html:151 #: warehouse/templates/manage/organization/roles.html:270 @@ -4059,7 +4060,7 @@ msgstr "" #: warehouse/templates/manage/manage_base.html:366 #: warehouse/templates/manage/manage_base.html:418 -#: warehouse/templates/manage/organization/activate_subscription.html:32 +#: warehouse/templates/manage/organization/activate_subscription.html:52 msgid "Cancel" msgstr "" @@ -5124,17 +5125,28 @@ msgid "" msgstr "" #: warehouse/templates/manage/organization/activate_subscription.html:17 -#: warehouse/templates/manage/organization/activate_subscription.html:21 -#: warehouse/templates/manage/organization/activate_subscription.html:35 +#: warehouse/templates/manage/organization/activate_subscription.html:22 +#: warehouse/templates/manage/organization/activate_subscription.html:54 msgid "Activate Subscription" msgstr "" -#: warehouse/templates/manage/organization/activate_subscription.html:27 +#: warehouse/templates/manage/organization/activate_subscription.html:26 msgid "" "Company accounts require an active subscription. Please enter up-to-date " "billing information to enable the account." msgstr "" +#: warehouse/templates/manage/organization/activate_subscription.html:33 +msgid "Terms of Service" +msgstr "" + +#: warehouse/templates/manage/organization/activate_subscription.html:37 +#, python-format +msgid "" +"I agree to the PyPI Terms of Service on " +"behalf of the %(organization_name)s organization." +msgstr "" + #: warehouse/templates/manage/organization/history.html:20 #: warehouse/templates/manage/project/history.html:20 #: warehouse/templates/manage/team/history.html:20 diff --git a/warehouse/manage/forms.py b/warehouse/manage/forms.py index 58539de2c435..cfe8293bf2dc 100644 --- a/warehouse/manage/forms.py +++ b/warehouse/manage/forms.py @@ -382,6 +382,17 @@ def validate_macaroon_id(self, field): # /manage/organizations/ forms +class OrganizationActivateBillingForm(wtforms.Form): + terms_of_service_agreement = wtforms.BooleanField( + validators=[ + wtforms.validators.DataRequired( + message="Terms of Service must be accepted.", + ), + ], + default=False, + ) + + class OrganizationRoleNameMixin: role_name = wtforms.SelectField( "Select role", diff --git a/warehouse/manage/views/organizations.py b/warehouse/manage/views/organizations.py index 5e618526707d..5cf08c7f7461 100644 --- a/warehouse/manage/views/organizations.py +++ b/warehouse/manage/views/organizations.py @@ -51,6 +51,7 @@ CreateOrganizationApplicationForm, CreateOrganizationRoleForm, CreateTeamForm, + OrganizationActivateBillingForm, SaveOrganizationForm, SaveOrganizationNameForm, TransferOrganizationProjectForm, @@ -554,9 +555,17 @@ def manage_subscription(self): renderer="manage/organization/activate_subscription.html", ) def activate_subscription(self): - # We're not ready for companies to activate their own subscriptions yet. - raise HTTPNotFound() - # return {"organization": self.organization} + form = OrganizationActivateBillingForm(self.request.POST) + if self.request.method == "POST" and form.validate(): + self.organization_service.add_organization_terms_of_service_agreement( + self.organization.id + ) + route = self.request.route_path( + "manage.organization.subscription", + organization_name=self.organization.normalized_name, + ) + return HTTPSeeOther(route) + return {"organization": self.organization, "form": form} @view_config(route_name="manage.organization.subscription") def create_or_manage_subscription(self): diff --git a/warehouse/migrations/versions/5bc11bd312e5_create_organization_terms_of_service_.py b/warehouse/migrations/versions/5bc11bd312e5_create_organization_terms_of_service_.py new file mode 100644 index 000000000000..cadf913e11a4 --- /dev/null +++ b/warehouse/migrations/versions/5bc11bd312e5_create_organization_terms_of_service_.py @@ -0,0 +1,86 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +create organization terms of service agreement model + +Revision ID: 5bc11bd312e5 +Revises: f7720656a33c +Create Date: 2025-01-09 17:58:22.830648 +""" + +import sqlalchemy as sa + +from alembic import op + +import warehouse + +revision = "5bc11bd312e5" +down_revision = "f7720656a33c" + +# Note: It is VERY important to ensure that a migration does not lock for a +# long period of time and to ensure that each individual migration does +# not break compatibility with the *previous* version of the code base. +# This is because the migrations will be ran automatically as part of the +# deployment process, but while the previous version of the code is still +# up and running. Thus backwards incompatible changes must be broken up +# over multiple migrations inside of multiple pull requests in order to +# phase them in over multiple deploys. +# +# By default, migrations cannot wait more than 4s on acquiring a lock +# and each individual statement cannot take more than 5s. This helps +# prevent situations where a slow migration takes the entire site down. +# +# If you need to increase this timeout for a migration, you can do so +# by adding: +# +# op.execute("SET statement_timeout = 5000") +# op.execute("SET lock_timeout = 4000") +# +# To whatever values are reasonable for this migration as part of your +# migration. + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "organization_terms_of_service_agreements", + sa.Column("organization_id", sa.UUID(), nullable=False), + sa.Column("agreed", warehouse.utils.db.types.TZDateTime(), nullable=True), + sa.Column("notified", warehouse.utils.db.types.TZDateTime(), nullable=True), + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "organization_terms_of_service_agreements_organization_id_idx", + "organization_terms_of_service_agreements", + ["organization_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + "organization_terms_of_service_agreements_organization_id_idx", + table_name="organization_terms_of_service_agreements", + ) + op.drop_table("organization_terms_of_service_agreements") + # ### end Alembic commands ### diff --git a/warehouse/organizations/interfaces.py b/warehouse/organizations/interfaces.py index a61016859181..823724b882c6 100644 --- a/warehouse/organizations/interfaces.py +++ b/warehouse/organizations/interfaces.py @@ -166,6 +166,12 @@ def delete_organization_project(organization_id, project_id): Removes an association between the specified organization and project """ + def add_organization_terms_of_service_agreement(organization_id, notified=False): + """ + Add a record of end user agreeing to terms of service, + or being notified of a terms of service change. + """ + def get_organization_subscription(organization_id, subscription_id): """ Return the organization subscription object that represents the given diff --git a/warehouse/organizations/models.py b/warehouse/organizations/models.py index b5f14d77a18a..4a2b5c6791a0 100644 --- a/warehouse/organizations/models.py +++ b/warehouse/organizations/models.py @@ -43,7 +43,7 @@ from warehouse.authnz import Permissions from warehouse.events.models import HasEvents from warehouse.utils.attrs import make_repr -from warehouse.utils.db.types import bool_false, datetime_now +from warehouse.utils.db.types import TZDateTime, bool_false, datetime_now if typing.TYPE_CHECKING: from pyramid.request import Request @@ -143,6 +143,26 @@ class OrganizationStripeSubscription(db.Model): subscription: Mapped[StripeSubscription] = relationship(lazy=False) +class OrganizationTermsOfServiceAgreement(db.Model): + __tablename__ = "organization_terms_of_service_agreements" + __table_args__ = ( + Index( + "organization_terms_of_service_agreements_organization_id_idx", + "organization_id", + ), + ) + + __repr__ = make_repr("organization_id") + + organization_id: Mapped[UUID] = mapped_column( + ForeignKey("organizations.id", onupdate="CASCADE", ondelete="CASCADE"), + ) + agreed: Mapped[datetime.datetime | None] = mapped_column(TZDateTime) + notified: Mapped[datetime.datetime | None] = mapped_column(TZDateTime) + + organization: Mapped[Organization] = relationship(lazy=False) + + class OrganizationStripeCustomer(db.Model): __tablename__ = "organization_stripe_customers" __table_args__ = ( @@ -302,6 +322,12 @@ class Organization(OrganizationMixin, HasEvents, db.Model): back_populates="organization", viewonly=True, ) + terms_of_service_agreements: Mapped[list[OrganizationTermsOfServiceAgreement]] = ( + relationship( + back_populates="organization", + viewonly=True, + ) + ) @property def owners(self): diff --git a/warehouse/organizations/services.py b/warehouse/organizations/services.py index cd1abb76e2c2..109da57a987d 100644 --- a/warehouse/organizations/services.py +++ b/warehouse/organizations/services.py @@ -9,6 +9,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import datetime from sqlalchemy import delete, func, orm, select from sqlalchemy.exc import NoResultFound @@ -35,6 +36,7 @@ OrganizationRoleType, OrganizationStripeCustomer, OrganizationStripeSubscription, + OrganizationTermsOfServiceAgreement, Team, TeamProjectRole, TeamRole, @@ -534,6 +536,22 @@ def delete_organization_project(self, organization_id, project_id): self.db.delete(organization_project) + def add_organization_terms_of_service_agreement( + self, organization_id, notified=False + ): + """ + Add a record of end user agreeing to terms of service, + or being notified of a terms of service change. + """ + terms_of_service_agreement = OrganizationTermsOfServiceAgreement( + organization_id=organization_id + ) + if notified: + terms_of_service_agreement.notified = datetime.datetime.now(tz=datetime.UTC) + else: + terms_of_service_agreement.agreed = datetime.datetime.now(tz=datetime.UTC) + self.db.add(terms_of_service_agreement) + def get_organization_subscription(self, organization_id, subscription_id): """ Return the organization subscription object that represents the given diff --git a/warehouse/static/js/warehouse/controllers/organization_terms_of_service_accepted_controller.js b/warehouse/static/js/warehouse/controllers/organization_terms_of_service_accepted_controller.js new file mode 100644 index 000000000000..1a5ba522ca02 --- /dev/null +++ b/warehouse/static/js/warehouse/controllers/organization_terms_of_service_accepted_controller.js @@ -0,0 +1,31 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["termsOfServiceAccepted", "submit"]; + + connect() { + this.checkTermsOfServiceAccepted(); + } + + checkTermsOfServiceAccepted() { + if (this.termsOfServiceAcceptedTarget.checked) { + this.submitTarget.removeAttribute("disabled"); + } else { + this.submitTarget.setAttribute("disabled", ""); + } + } +} diff --git a/warehouse/static/sass/blocks/_form-group.scss b/warehouse/static/sass/blocks/_form-group.scss index f1dc8692fe65..87846f57f43b 100644 --- a/warehouse/static/sass/blocks/_form-group.scss +++ b/warehouse/static/sass/blocks/_form-group.scss @@ -44,6 +44,11 @@ font-weight: normal; } + &__wide { + margin-bottom: $half-spacing-unit; + max-width: unset; + } + :where( input:not([type]), select, diff --git a/warehouse/subscriptions/services.py b/warehouse/subscriptions/services.py index 310a9624f096..8464467815c5 100644 --- a/warehouse/subscriptions/services.py +++ b/warehouse/subscriptions/services.py @@ -76,6 +76,7 @@ def create_customer(self, name, description): return self.api.Customer.create( name=name, description=description, + metadata={"billing_service": "pypi"}, ) def update_customer(self, customer_id, name, description): @@ -96,6 +97,7 @@ def create_checkout_session(self, customer_id, price_ids, success_url, cancel_ur cancel_url=cancel_url, mode="subscription", line_items=[{"price": price_id} for price_id in price_ids], + metadata={"billing_service": "pypi"}, # Uncomment `automatic_tax` to calculate tax automatically. # Requires active tax settings on Stripe Dashboard. # https://dashboard.stripe.com/settings/tax/activate @@ -152,6 +154,7 @@ def create_product(self, name, description, tax_code, unit_label): description=description, tax_code=tax_code, unit_label=unit_label, + metadata={"billing_service": "pypi"}, ) def retrieve_product(self, product_id): @@ -253,6 +256,7 @@ def create_price(self, unit_amount, currency, product_id, tax_behavior): }, product=product_id, tax_behavior=tax_behavior, + metadata={"billing_service": "pypi"}, ) def retrieve_price(self, price_id): @@ -650,7 +654,7 @@ def get_or_create_default_subscription_price(self): price_id=None, currency="usd", subscription_product_id=subscription_product.id, - unit_amount=700, + unit_amount=500, recurring=StripeSubscriptionPriceInterval.Month, tax_behavior="inclusive", ) diff --git a/warehouse/templates/manage/organization/activate_subscription.html b/warehouse/templates/manage/organization/activate_subscription.html index 6919e62f1426..b21ae967caaa 100644 --- a/warehouse/templates/manage/organization/activate_subscription.html +++ b/warehouse/templates/manage/organization/activate_subscription.html @@ -11,28 +11,47 @@ # See the License for the specific language governing permissions and # limitations under the License. -#} -{% extends "confirm-action.html" %} +{% extends "manage_organization_base.html" %} {% block title %} {% trans %}Activate Subscription{% endtrans %} {% endblock %} -{% block prompt %} - {% trans %}Activate Subscription{% endtrans %} -{% endblock %} {% block main %} -