From ac627dff1662293692eb9e9166a6ad75c2e28ef3 Mon Sep 17 00:00:00 2001 From: Kartik Ohri Date: Sat, 21 Dec 2024 21:04:42 +0530 Subject: [PATCH 01/23] Support user login and registration --- .../2024-12-11-add-user-table.sql | 18 + ...24-12-12-link-user-and-supporter-table.sql | 10 + admin/sql/create_foreign_keys.sql | 4 + admin/sql/create_primary_keys.sql | 1 + admin/sql/create_tables.sql | 17 +- config.py.example | 10 + consul_config.py.ctmpl | 10 + docker/docker-compose.dev.yml | 4 +- frontend/css/auth-page.less | 72 ++ frontend/css/main.less | 1 + frontend/js/src/Profile.tsx | 372 ++++++++ frontend/js/src/forms/LoginUser.tsx | 136 +++ frontend/js/src/forms/LostPassword.tsx | 99 +++ frontend/js/src/forms/LostUsername.tsx | 89 ++ ...pporterProfileEdit.tsx => ProfileEdit.tsx} | 58 +- frontend/js/src/forms/ResetPassword.tsx | 103 +++ frontend/js/src/forms/SignupCommercial.tsx | 791 ++++++++++-------- frontend/js/src/forms/SignupNonCommercial.tsx | 383 ++++++--- frontend/js/src/forms/SignupUser.tsx | 230 +++++ frontend/js/src/forms/UserProfileEdit.tsx | 88 ++ frontend/js/src/forms/utils.tsx | 112 ++- frontend/js/src/types.d.ts | 32 + metabrainz/__init__.py | 35 +- metabrainz/admin/__init__.py | 4 +- metabrainz/admin/forms.py | 5 +- metabrainz/admin/views.py | 14 +- metabrainz/db/supporter.py | 6 +- metabrainz/index/__init__.py | 0 metabrainz/index/forms.py | 75 ++ metabrainz/index/views.py | 237 ++++++ metabrainz/{ => index}/views_test.py | 0 metabrainz/model/access_log.py | 48 +- metabrainz/model/access_log_test.py | 2 - metabrainz/model/payment.py | 31 +- metabrainz/model/supporter.py | 80 +- metabrainz/model/tier.py | 2 +- metabrainz/model/token_log.py | 5 +- metabrainz/model/user.py | 59 ++ metabrainz/payments/views.py | 2 +- metabrainz/supporter/__init__.py | 22 - metabrainz/supporter/forms.py | 66 +- metabrainz/supporter/musicbrainz_login.py | 67 -- metabrainz/supporter/views.py | 407 ++++----- metabrainz/supporter/views_test.py | 10 +- .../index.html | 4 +- metabrainz/templates/admin/home.html | 8 +- metabrainz/templates/admin/master.html | 3 +- .../templates/admin/stats/overview.html | 4 +- .../templates/admin/stats/token-log.html | 2 +- metabrainz/templates/admin/stats/top-ips.html | 4 +- .../templates/admin/stats/top-tokens.html | 4 +- .../templates/admin/supporters/details.html | 12 +- .../templates/admin/supporters/edit.html | 12 +- .../templates/admin/supporters/index.html | 6 +- metabrainz/templates/api/info.html | 2 +- metabrainz/templates/base.html | 6 +- ...ial-welcome-email-address-verification.txt | 20 + ...ial-welcome-email-address-verification.txt | 17 + .../email/user-email-address-verification.txt | 15 + .../templates/email/user-forgot-username.txt | 13 + .../templates/email/user-password-reset.txt | 17 + .../{supporters => index}/profile-edit.html | 2 +- metabrainz/templates/index/profile.html | 8 + metabrainz/templates/navbar.html | 8 +- metabrainz/templates/supporters/mb-login.html | 15 - .../templates/supporters/mb-signup.html | 19 - metabrainz/templates/supporters/profile.html | 215 ----- metabrainz/templates/users/base.html | 34 + metabrainz/templates/users/login.html | 8 + metabrainz/templates/users/lost-password.html | 8 + metabrainz/templates/users/lost-username.html | 8 + .../templates/users/reset-password.html | 8 + metabrainz/templates/users/signup.html | 8 + metabrainz/user/__init__.py | 23 + metabrainz/user/email.py | 68 ++ metabrainz/user/forms.py | 57 ++ metabrainz/user/views.py | 237 ++++++ metabrainz/views.py | 126 --- requirements.txt | 12 +- webpack.config.js | 11 +- 80 files changed, 3368 insertions(+), 1473 deletions(-) create mode 100644 admin/schema_updates/2024-12-11-add-user-table.sql create mode 100644 admin/schema_updates/2024-12-12-link-user-and-supporter-table.sql create mode 100644 frontend/css/auth-page.less create mode 100644 frontend/js/src/Profile.tsx create mode 100644 frontend/js/src/forms/LoginUser.tsx create mode 100644 frontend/js/src/forms/LostPassword.tsx create mode 100644 frontend/js/src/forms/LostUsername.tsx rename frontend/js/src/forms/{SupporterProfileEdit.tsx => ProfileEdit.tsx} (67%) create mode 100644 frontend/js/src/forms/ResetPassword.tsx create mode 100644 frontend/js/src/forms/SignupUser.tsx create mode 100644 frontend/js/src/forms/UserProfileEdit.tsx create mode 100644 metabrainz/index/__init__.py create mode 100644 metabrainz/index/forms.py create mode 100644 metabrainz/index/views.py rename metabrainz/{ => index}/views_test.py (100%) create mode 100644 metabrainz/model/user.py delete mode 100644 metabrainz/supporter/musicbrainz_login.py rename metabrainz/templates/admin/{commercial-users => commercial-supporters}/index.html (96%) create mode 100644 metabrainz/templates/email/supporter-commercial-welcome-email-address-verification.txt create mode 100644 metabrainz/templates/email/supporter-noncommercial-welcome-email-address-verification.txt create mode 100644 metabrainz/templates/email/user-email-address-verification.txt create mode 100644 metabrainz/templates/email/user-forgot-username.txt create mode 100644 metabrainz/templates/email/user-password-reset.txt rename metabrainz/templates/{supporters => index}/profile-edit.html (65%) create mode 100644 metabrainz/templates/index/profile.html delete mode 100644 metabrainz/templates/supporters/mb-login.html delete mode 100644 metabrainz/templates/supporters/mb-signup.html delete mode 100644 metabrainz/templates/supporters/profile.html create mode 100644 metabrainz/templates/users/base.html create mode 100644 metabrainz/templates/users/login.html create mode 100644 metabrainz/templates/users/lost-password.html create mode 100644 metabrainz/templates/users/lost-username.html create mode 100644 metabrainz/templates/users/reset-password.html create mode 100644 metabrainz/templates/users/signup.html create mode 100644 metabrainz/user/__init__.py create mode 100644 metabrainz/user/email.py create mode 100644 metabrainz/user/forms.py create mode 100644 metabrainz/user/views.py delete mode 100644 metabrainz/views.py diff --git a/admin/schema_updates/2024-12-11-add-user-table.sql b/admin/schema_updates/2024-12-11-add-user-table.sql new file mode 100644 index 00000000..ac31b754 --- /dev/null +++ b/admin/schema_updates/2024-12-11-add-user-table.sql @@ -0,0 +1,18 @@ +BEGIN; + +CREATE TABLE "user" ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY, + name TEXT NOT NULL, + password TEXT NOT NULL, + email TEXT UNIQUE, + unconfirmed_email TEXT, + email_confirmed_at TIMESTAMP WITH TIME ZONE, + member_since TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMP WITH TIME ZONE, + last_updated TIMESTAMP WITH TIME ZONE, + deleted BOOLEAN +); + +ALTER TABLE "user" ADD CONSTRAINT user_pkey PRIMARY KEY (id); + +COMMIT; diff --git a/admin/schema_updates/2024-12-12-link-user-and-supporter-table.sql b/admin/schema_updates/2024-12-12-link-user-and-supporter-table.sql new file mode 100644 index 00000000..60ff53fe --- /dev/null +++ b/admin/schema_updates/2024-12-12-link-user-and-supporter-table.sql @@ -0,0 +1,10 @@ +BEGIN; + +ALTER TABLE supporter ADD COLUMN user_id INTEGER; +UPDATE supporter SET user_id = musicbrainz_row_id; + +ALTER TABLE supporter ADD CONSTRAINT supporter_user_id_fkey + FOREIGN KEY (user_id) REFERENCES "user" (id) + ON UPDATE CASCADE ON DELETE SET NULL; + +COMMIT; diff --git a/admin/sql/create_foreign_keys.sql b/admin/sql/create_foreign_keys.sql index b0fc6ca8..eb26810b 100644 --- a/admin/sql/create_foreign_keys.sql +++ b/admin/sql/create_foreign_keys.sql @@ -5,6 +5,10 @@ ALTER TABLE token REFERENCES supporter (id) MATCH SIMPLE ON UPDATE CASCADE ON DELETE SET NULL; +ALTER TABLE supporter ADD CONSTRAINT supporter_user_id_fkey + FOREIGN KEY (user_id) REFERENCES "user" (id) + ON UPDATE CASCADE ON DELETE SET NULL; + ALTER TABLE supporter ADD CONSTRAINT supporter_tier_id_fkey FOREIGN KEY (tier_id) REFERENCES tier (id) MATCH SIMPLE diff --git a/admin/sql/create_primary_keys.sql b/admin/sql/create_primary_keys.sql index 0cc42a1f..5056c5b4 100644 --- a/admin/sql/create_primary_keys.sql +++ b/admin/sql/create_primary_keys.sql @@ -1,5 +1,6 @@ BEGIN; +ALTER TABLE "user" ADD CONSTRAINT user_pkey PRIMARY KEY (id); ALTER TABLE tier ADD CONSTRAINT tier_pkey PRIMARY KEY (id); ALTER TABLE supporter ADD CONSTRAINT supporter_pkey PRIMARY KEY (id); ALTER TABLE token ADD CONSTRAINT token_pkey PRIMARY KEY (value); diff --git a/admin/sql/create_tables.sql b/admin/sql/create_tables.sql index fbdbba9c..b0ca07b6 100644 --- a/admin/sql/create_tables.sql +++ b/admin/sql/create_tables.sql @@ -1,5 +1,18 @@ BEGIN; +CREATE TABLE "user" ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY, + name TEXT NOT NULL, + password TEXT NOT NULL, + email TEXT UNIQUE, + unconfirmed_email TEXT, + email_confirmed_at TIMESTAMP WITH TIME ZONE, + member_since TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMP WITH TIME ZONE, + last_updated TIMESTAMP WITH TIME ZONE, + deleted BOOLEAN +); + CREATE TABLE tier ( id SERIAL NOT NULL, -- PK name CHARACTER VARYING NOT NULL, @@ -20,12 +33,10 @@ CREATE TABLE dataset ( CREATE TABLE supporter ( id SERIAL NOT NULL, -- PK is_commercial BOOLEAN NOT NULL, - musicbrainz_id CHARACTER VARYING UNIQUE, - musicbrainz_row_id INTEGER UNIQUE, + user_id INTEGER UNIQUE, created TIMESTAMP WITH TIME ZONE, state state_types NOT NULL, contact_name CHARACTER VARYING NOT NULL, - contact_email CHARACTER VARYING NOT NULL, data_usage_desc TEXT, org_name CHARACTER VARYING, logo_filename CHARACTER VARYING, diff --git a/config.py.example b/config.py.example index 01c94cd3..2c541193 100644 --- a/config.py.example +++ b/config.py.example @@ -1,9 +1,19 @@ +from datetime import timedelta + # CUSTOM CONFIGURATION DEBUG = True # set to False in production mode SECRET_KEY = "CHANGE_THIS" +EMAIL_VERIFICATION_SECRET_KEY = "CHANGE THIS" +EMAIL_VERIFICATION_EXPIRY = timedelta(hours=24) +EMAIL_RESET_PASSWORD_EXPIRY = timedelta(hours=24) + +# Bcrypt +BCRYPT_HASH_PREFIX = "2a" +BCRYPT_LOG_ROUNDS = 12 + # DATABASE SQLALCHEMY_DATABASE_URI = "postgresql://metabrainz:metabrainz@meb_db:5432/metabrainz" SQLALCHEMY_MUSICBRAINZ_URI = "" diff --git a/consul_config.py.ctmpl b/consul_config.py.ctmpl index 766e7e92..66039627 100644 --- a/consul_config.py.ctmpl +++ b/consul_config.py.ctmpl @@ -10,9 +10,19 @@ {{- end -}} {{- end -}} +from datetime import timedelta + SECRET_KEY = '''{{template "KEY" "secret_key"}}''' DEBUG = False +EMAIL_VERIFICATION_SECRET_KEY = '''{{template "KEY" "email_verification_secret_key"}}''' +EMAIL_VERIFICATION_EXPIRY = timedelta(hours=24) +EMAIL_RESET_PASSWORD_EXPIRY = timedelta(hours=24) + +# Bcrypt +BCRYPT_HASH_PREFIX = "2a" +BCRYPT_LOG_ROUNDS = 12 + {{if service "pgbouncer-master"}} {{with index (service "pgbouncer-master") 0}} SQLALCHEMY_DATABASE_URI = "postgresql://{{template "KEY" "postgresql/username"}}:{{template "KEY" "postgresql/password"}}@{{.Address}}:{{.Port}}/{{template "KEY" "postgresql/db_name"}}" diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 8b3a1677..ddf57d3c 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -9,7 +9,9 @@ services: context: .. dockerfile: Dockerfile target: metabrainz-dev - command: python manage.py runserver -h 0.0.0.0 -p 8000 + command: flask run --debug -h 0.0.0.0 -p 8000 + environment: + FLASK_APP: "metabrainz:create_app()" volumes: - ../data/replication_packets:/data/replication_packets - ../data/json_dumps:/data/json_dumps diff --git a/frontend/css/auth-page.less b/frontend/css/auth-page.less new file mode 100644 index 00000000..9dadcd6e --- /dev/null +++ b/frontend/css/auth-page.less @@ -0,0 +1,72 @@ +#auth-page { + + font-style: normal; + font-weight: 400; + min-height: 500px; + background: linear-gradient(90deg, #3B9766 0%, #FFA500 100%); + margin: 0 -1em; + padding: 2em; + + .form-label{ + font-weight: normal; + } + + .icon-pills { + display: flex; + justify-content: space-evenly; + margin-bottom: 2rem; + } + .icon-pill { + background: #D9D9D9; + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.2); + border-radius: 50%; + text-align: center; + width:50px; + height: 50px; + display: flex; + align-items: center; + justify-content: center; + img { + width:65%; + } + } + + .auth-page-container { + max-width: 400px; + margin-left: auto; + margin-right: auto; + } + .auth-card-container { + background: #E7E7E7; + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.2); + border-radius: 3px; + } + .auth-card { + h1,h2,h3,h4,h5,h6{ + font-weight: bold; + } + background: #FFFFFF; + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.2); + padding: 1rem; + border-radius: 3px; + } + .auth-card-bottom { + display: flex; + align-items: center; + justify-content: space-between; + } + .auth-card-footer { + padding: 1rem; + font-size: 1.3rem; + line-height: 1.6rem; + color: #808080; + } + .main-action-button { + display: block; + font-size: 1.1em; + margin: 1em auto; + } + .modal-content { + padding:1.5em; + } +} diff --git a/frontend/css/main.less b/frontend/css/main.less index 0d642ed6..56c47162 100644 --- a/frontend/css/main.less +++ b/frontend/css/main.less @@ -1,5 +1,6 @@ @import "theme/theme.less"; @import "carousel.less"; +@import "auth-page.less"; // fixme: need to make it configurable for production and development and meb.org container and oauth container @icon-font-path:"/static/fonts/"; diff --git a/frontend/js/src/Profile.tsx b/frontend/js/src/Profile.tsx new file mode 100644 index 00000000..41576090 --- /dev/null +++ b/frontend/js/src/Profile.tsx @@ -0,0 +1,372 @@ +import React, { JSX, useCallback, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { getPageProps } from "./utils"; + +type ProfileProps = { + user: User; + csrf_token?: string; +}; + +type EmailProps = { + label: string; + is_email_confirmed: boolean; + email: string; + csrf_token?: string; +}; + +function Email({ + label, + is_email_confirmed, + email, + csrf_token, +}: EmailProps): JSX.Element { + return ( + <> + {label}: {email}{" "} + {is_email_confirmed ? ( + + Verified + + ) : ( + <> + + Unverified + +
+ + +
+ + )} +
+ + ); +} + +function SupporterProfile({ user, csrf_token }: ProfileProps) { + const { email, is_email_confirmed, supporter } = user; + const { + is_commercial, + tier, + state, + contact_name, + org_name, + website_url, + api_url, + datasets, + good_standing, + token, + } = supporter!!; + const [currentToken, setCurrentToken] = useState(token); + + const regenerateToken = useCallback(async () => { + if ( + !currentToken || + // eslint-disable-next-line no-alert + window.confirm( + "Are you sure you want to generate new access token? Current token will be revoked!" + ) + ) { + const response = await fetch("/supporters/profile/regenerate-token", { + method: "POST", + }); + if (!response.ok) { + // eslint-disable-next-line no-alert + window.alert("Failed to generate new access token!"); + } else { + const data = await response.json(); + setCurrentToken(data.token); + } + } + }, [currentToken]); + + let applicationState; + if (state === "rejected") { + applicationState = ( + <> +

+ + Your application for using the Live Data Feed has been rejected. + +

+

+ You do not have permission to use our data in a public commercial + product. +

+ + ); + } else if (state === "pending") { + applicationState = ( + <> +

+ Your application for using the Live Data Feed is still pending. +

+

+ You may use our data and APIs for evaluation/development purposes + while your application is pending. +

+ + ); + } else if (state === "waiting") { + applicationState = ( + <> +

+ + Your application for using the Live Data Feed is waiting to finalize + our support agreement. + +

+

+ You may use our data and APIs for evaluation and development purposes, + but you may not use the data in a public commercial product. Once you + are nearing the public release of a product that contains our data, + please + contact us again to finalize our support + agreement. +

+ + ); + } else if (!good_standing) { + applicationState = ( + <> +

+ Your use of the Live Data Feed is pending suspension. +

+

+ Your account is in bad standing, which means that you are more than 60 + days behind in support payments. If you think this is a mistake, + please contact us. +

+ + ); + } else { + applicationState =

Unknown. :(

; + } + + let stateClass; + if (state === "active") { + stateClass = "text-success"; + } else if (state === "rejected") { + stateClass = "text-danger"; + } else if (state === "pending") { + stateClass = "text-primary"; + } else { + stateClass = "text-warning"; + } + return ( + <> +

+ Type: {is_commercial ? "Commercial" : "Non-commercial"} +
+ {is_commercial && ( + <> + Tier: {tier.name} +
+ + )} + State: + {state.toUpperCase()} +

+ {!is_commercial && ( +

+ NOTE: If you would like to change your account from non-commercial to + commercial, please + contact us. +

+ )} +
+ {is_commercial && ( +
+

Organization information

+

+ Name:{" "} + {org_name || Unspecified} +
+ Website URL:{" "} + {website_url || Unspecified} +
+ API URL:{" "} + {api_url || Unspecified} +
+

+

+ Please contact us if you wish for us to + update this information. +

+
+ )} +
+

Contact information

+

+ Contact Name: {contact_name} +
+ + {!is_commercial && ( + <> + Datasets used:{" "} + {datasets.map((d) => d.name).join(", ")} +
+ + )} +

+

+ + {is_commercial + ? "Edit contact information" + : "Edit datasets/contact information"} + +

+
+
+ {is_commercial && + ((state === "active" || state === "limited") && good_standing ? ( + <> +

Data use permission granted

+
+

+ Your support agreement has been completed -- thank you! +

+

+ You have permission to use any of the data published by the + MetaBrainz Foundation. This includes data dumps released under a + Creative Commons non-commercial license. Thank you for your + support! +

+

+ Note 1: If your support falls behind by more than 60 days, this + permission may be withdrawn. You can always check your current + permission status on this page. +

+

+ Note 2: The IP addresses from which replication packets for the + Live Data Feed are downloaded are logged. +

+
+ + ) : ( + <> +

Limited/no data use permission granted

+
{applicationState}
+ + ))} + {(state === "active" || state === "limited") && good_standing && ( + <> +

Data Download

+
+ {!is_commercial && ( +

+ Thank you for registering with us -- we really appreciate it! +

+ )} +

Please proceed to our download page to download our datasets:

+

+ + Download Datasets + +

+
+ + )} + {state === "active" && ( + <> +

Live Data Feed Access token

+

+ This access token should be considered private. Don't check + this token into any publicly visible version control systems and + similar places. If the token has been exposed, you should + immediately immediately generate a new one! When you generate a new + token, your token is revoked. +

+
+

+

+ {currentToken || "[ there is no valid token currently ]"} +
+

+

+ +

+
+

+ See the API documentation for more information. +

+ + )} + + ); +} + +function UserProfile({ user, csrf_token }: ProfileProps): JSX.Element { + const { name, is_email_confirmed, email } = user; + return ( + <> +

Contact information

+

+ Name: {name} +
+ +

+

+ + Edit information + +

+ + ); +} + +function Profile({ user, csrf_token }: ProfileProps): JSX.Element { + return ( + <> +

Your Profile

+ {user.supporter ? ( + + ) : ( + + )} + + ); +} + +document.addEventListener("DOMContentLoaded", () => { + const { domContainer, reactProps } = getPageProps(); + const { user, csrf_token } = reactProps; + + const renderRoot = createRoot(domContainer!); + renderRoot.render(); +}); diff --git a/frontend/js/src/forms/LoginUser.tsx b/frontend/js/src/forms/LoginUser.tsx new file mode 100644 index 00000000..bfeaa51b --- /dev/null +++ b/frontend/js/src/forms/LoginUser.tsx @@ -0,0 +1,136 @@ +import { Formik } from "formik"; +import React, { JSX } from "react"; +import { createRoot } from "react-dom/client"; +import * as Yup from "yup"; +import { getPageProps } from "../utils"; +import { + AuthCardCheckboxInput, + AuthCardContainer, + AuthCardTextInput, +} from "./utils"; + +type LoginUserProps = { + csrf_token: string; + initial_form_data: any; + initial_errors: any; +}; + +function LoginUser({ + csrf_token, + initial_form_data, + initial_errors, +}: LoginUserProps): JSX.Element { + return ( + +
+
+

Welcome back!

+ {}} + > + {({ errors }) => ( +
+
+
+ +
+ {errors.csrf_token && ( +
+ {errors.csrf_token} +
+ )} +
+ + + Username{" "} + + (public) + + + } + type="text" + name="username" + id="username" + required + /> + + + +
+ + + + + I forgot my username /{" "} + password + +
+ + + + )} +
+
+
+ Don‘t have an account? + Create a free MetaBrainz account to access + MusicBrainz, ListenBrainz, CritiqueBrainz, and more. +
+
+
+
+
+ ); +} + +document.addEventListener("DOMContentLoaded", () => { + const { domContainer, reactProps } = getPageProps(); + const { csrf_token, initial_form_data, initial_errors } = reactProps; + + const renderRoot = createRoot(domContainer!); + renderRoot.render( + + ); +}); diff --git a/frontend/js/src/forms/LostPassword.tsx b/frontend/js/src/forms/LostPassword.tsx new file mode 100644 index 00000000..f2c44446 --- /dev/null +++ b/frontend/js/src/forms/LostPassword.tsx @@ -0,0 +1,99 @@ +import { Formik } from "formik"; +import React, { JSX } from "react"; +import { createRoot } from "react-dom/client"; +import * as Yup from "yup"; +import { getPageProps } from "../utils"; +import { AuthCardContainer, AuthCardTextInput } from "./utils"; + +type LostPasswordProps = { + csrf_token: string; + initial_form_data: any; + initial_errors: any; +}; + +function LostPassword({ + csrf_token, + initial_form_data, + initial_errors, +}: LostPasswordProps): JSX.Element { + return ( + +
+
+

Forgot your password?

+ {}} + > + {({ errors }) => ( +
+
+
+ +
+ {errors.csrf_token && ( +
+ {errors.csrf_token} +
+ )} +
+ + + + + + + + )} +
+
+
+
+ ); +} + +document.addEventListener("DOMContentLoaded", () => { + const { domContainer, reactProps } = getPageProps(); + const { csrf_token, initial_form_data, initial_errors } = reactProps; + + const renderRoot = createRoot(domContainer!); + renderRoot.render( + + ); +}); diff --git a/frontend/js/src/forms/LostUsername.tsx b/frontend/js/src/forms/LostUsername.tsx new file mode 100644 index 00000000..e869cb31 --- /dev/null +++ b/frontend/js/src/forms/LostUsername.tsx @@ -0,0 +1,89 @@ +import { Formik } from "formik"; +import React, { JSX } from "react"; +import { createRoot } from "react-dom/client"; +import * as Yup from "yup"; +import { getPageProps } from "../utils"; +import { AuthCardContainer, AuthCardTextInput } from "./utils"; + +type LostUsernameProps = { + csrf_token: string; + initial_form_data: any; + initial_errors: any; +}; + +function LostUsername({ + csrf_token, + initial_form_data, + initial_errors, +}: LostUsernameProps): JSX.Element { + return ( + +
+
+

Forgot your username?

+ {}} + > + {({ errors }) => ( +
+
+
+ +
+ {errors.csrf_token && ( +
+ {errors.csrf_token} +
+ )} +
+ + + + + + )} +
+
+
+
+ ); +} + +document.addEventListener("DOMContentLoaded", () => { + const { domContainer, reactProps } = getPageProps(); + const { csrf_token, initial_form_data, initial_errors } = reactProps; + + const renderRoot = createRoot(domContainer!); + renderRoot.render( + + ); +}); diff --git a/frontend/js/src/forms/SupporterProfileEdit.tsx b/frontend/js/src/forms/ProfileEdit.tsx similarity index 67% rename from frontend/js/src/forms/SupporterProfileEdit.tsx rename to frontend/js/src/forms/ProfileEdit.tsx index b3ed8b53..c4ea8597 100644 --- a/frontend/js/src/forms/SupporterProfileEdit.tsx +++ b/frontend/js/src/forms/ProfileEdit.tsx @@ -3,44 +3,46 @@ import React, { JSX } from "react"; import { createRoot } from "react-dom/client"; import * as Yup from "yup"; import { getPageProps } from "../utils"; -import { Dataset, DatasetsInput, TextInput } from "./utils"; +import { DatasetsInput, TextInput } from "./utils"; -type SupporterProfileEditProps = { +type ProfileEditProps = { datasets: Dataset[]; + is_supporter: boolean; is_commercial: boolean; csrf_token: string; initial_form_data: any; initial_errors: any; }; -function SupporterProfileEdit({ +function ProfileEdit({ datasets, is_commercial, + is_supporter, csrf_token, initial_form_data, initial_errors, -}: SupporterProfileEditProps): JSX.Element { +}: ProfileEditProps): JSX.Element { + const schema: any = { + email: Yup.string().email().required("Email address is required!"), + }; + if (is_supporter) { + schema.contact_name = Yup.string().required("Contact name is required!"); + } return ( <>

Your Profile

Edit contact information

- x.toString()) ?? [], - contact_name: initial_form_data.contact_name ?? "", - contact_email: initial_form_data.contact_email ?? "", + contact_name: initial_form_data?.contact_name ?? "", + email: initial_form_data?.email ?? "", csrf_token, }} initialErrors={initial_errors} initialTouched={initial_errors} - validationSchema={Yup.object({ - contact_name: Yup.string().required("Contact name is required!"), - contact_email: Yup.string() - .email() - .required("Email address is required!"), - })} + validationSchema={Yup.object(schema)} onSubmit={() => {}} > {({ errors }) => ( @@ -59,24 +61,28 @@ function SupporterProfileEdit({ )} - + {is_supporter && ( + + )}
- {!is_commercial && } + {is_supporter && !is_commercial && ( + + )}
@@ -93,10 +99,11 @@ function SupporterProfileEdit({ } document.addEventListener("DOMContentLoaded", () => { - const { domContainer, reactProps, globalProps } = getPageProps(); + const { domContainer, reactProps } = getPageProps(); const { datasets, is_commercial, + is_supporter, csrf_token, initial_form_data, initial_errors, @@ -104,8 +111,9 @@ document.addEventListener("DOMContentLoaded", () => { const renderRoot = createRoot(domContainer!); renderRoot.render( - +
+
+

Reset your password

+ {}} + > + {({ errors }) => ( +
+
+
+ +
+ {errors.csrf_token && ( +
+ {errors.csrf_token} +
+ )} +
+ + + + + + + + )} +
+
+
+ + ); +} + +document.addEventListener("DOMContentLoaded", () => { + const { domContainer, reactProps } = getPageProps(); + const { csrf_token, initial_errors } = reactProps; + + const renderRoot = createRoot(domContainer!); + renderRoot.render( + + ); +}); diff --git a/frontend/js/src/forms/SignupCommercial.tsx b/frontend/js/src/forms/SignupCommercial.tsx index 0c9e0ffc..5c8d5cf2 100644 --- a/frontend/js/src/forms/SignupCommercial.tsx +++ b/frontend/js/src/forms/SignupCommercial.tsx @@ -4,7 +4,12 @@ import { createRoot } from "react-dom/client"; import ReCAPTCHA from "react-google-recaptcha"; import * as Yup from "yup"; import { getPageProps } from "../utils"; -import { CheckboxInput, TextAreaInput, TextInput } from "./utils"; +import { + AuthCardContainer, + AuthCardTextInput, + CheckboxInput, + TextAreaInput, +} from "./utils"; type AmountPledgedFieldProps = JSX.IntrinsicElements["input"] & FieldConfig & { @@ -48,7 +53,6 @@ type SignupCommercialProps = { name: string; price: number; }; - mb_username: string; recaptcha_site_key: string; csrf_token: string; initial_form_data: any; @@ -57,367 +61,481 @@ type SignupCommercialProps = { function SignupCommercial({ tier, - mb_username, csrf_token, recaptcha_site_key, initial_form_data, initial_errors, }: SignupCommercialProps): JSX.Element { return ( - <> -

- Sign up Commercial -

-

- Note: Signing up for any tier other than the{" "} - Stealth startup tier will publicly list your company on this web - site. However, we will not publish any of your private details. -

- - {}} - > - {({ errors, setFieldValue }) => ( -
-
-
- -
- {errors.csrf_token && ( -
{errors.csrf_token}
- )} -
+ +
+
+

+ Sign up Commercial +

+
access all MetaBrainz projects
+

+ Note: Signing up for any tier other than the{" "} + Stealth startup tier will publicly list your company on this + web site. However, we will not publish any of your private details. +

-
-
- Account type -
-
- Commercial -
-
- Selected tier -
-
- {tier.name} - ${tier.price}/month and up -
- -
+ {}} + > + {({ errors, setFieldValue }) => ( + +
+
+ +
+ {errors.csrf_token && ( +
+ {errors.csrf_token} +
+ )} +
-
- - -
- - If you don't have an organization name, you probably want - to sign up as a{" "} - non-commercial / personal{" "} - user. - -
-
+
+
+ Account type +
+
+ Commercial +
+
+ Selected tier +
+
+ {tier.name} + ${tier.price}/month and up +
+ +
-
-
- MusicBrainz Account -
-
- {mb_username} -
-
+
- -
- - Please tell us a little about your company and whether you - plan to use our{" "} - - API - - or to{" "} - - host your own copy - {" "} - of the data. - -
-
- - - - -
- - Image should be about 250 pixels wide on a white or - transparent background. We will host it on our site. - -
-
- - -
- - URL to where developers can use your APIs using MusicBrainz - IDs, if available.. - -
-
- - -
- - -
- -
-
- Billing address -
-
+ + Username{" "} + + (public) + + + } + type="text" + name="username" + id="username" + required + /> + + - - - - - - - - - -
- - - - -
-
- - -

- I agree to support the MetaBrainz Foundation when my - organization is able to do so. -

-

- I also agree that if I generate a Live Data Feed access token, - that I treat my access token as a secret and will not share this - token publicly or commit it to a source code repository. -

-
- -
- - The following information will be shown publicly: organization - name, logo, website and API URLs, data usage description. + + + + +
-
-
-
- - We'll send you more details about payment process once your - application is approved. + +
+ + If you don't have an organization name, you probably + want to sign up as a{" "} + + non-commercial / personal + {" "} + user. + +
+
+ + +
+ + Please tell us a little about your company and whether you + plan to use our{" "} + + API + + or to{" "} + + host your own copy + {" "} + of the data. + +
+
+ + + + +
+ + Image should be about 250 pixels wide on a white or + transparent background. We will host it on our site. + +
+
+ + +
+ + URL to where developers can use your APIs using + MusicBrainz IDs, if available.. + +
+
+ +
+ +
-
-
-
-
- setFieldValue("recaptcha", value)} +
+
+ Billing address +
+
+ + + + + + -
-
-
-
- +
+
+
+ + )} +
+
+
+
+

+ Note that any contributions you make to MusicBrainz will be + released into the Public Domain and/or licensed under a + Creative Commons by-nc-sa license. Furthermore, you give the + MetaBrainz Foundation the right to license this data for + commercial use. +
+ Please read our + {" "} + license page + {" "} + for more details. +

+

+ MusicBrainz believes strongly in the privacy of its users. Any + personal information you choose to provide will not be sold or + shared with anyone else. +
+ Please read our privacy policy for more + details. +

+

+ You may remove your personal information from our services + anytime by deleting your account. +
+ Please read our + GDPR compliance statement + {" "} + for more details. +

+

+ Creating an account on MetaBrainz will give you access to + accounts on our other services, such as ListenBrainz, + MusicBrainz, BookBrainz, and more. +
+ We do not automatically create accounts for these services + when you create a MetaBrainz account, but you will only be a + few clicks away doing so. +

+
-
- - )} - - +
+
+
+ Already have an account? Sign in +
+
+
+
+
); } document.addEventListener("DOMContentLoaded", () => { - const { domContainer, reactProps, globalProps } = getPageProps(); + const { domContainer, reactProps } = getPageProps(); const { tier, - mb_username, recaptcha_site_key, csrf_token, initial_form_data, @@ -428,7 +546,6 @@ document.addEventListener("DOMContentLoaded", () => { renderRoot.render( -

- Sign up non-commercial -

-

- Please be aware that misuse of the non-commercial service for commercial - purposes will result in us revoking your access token and then billing - you for your commercial use of our datasets or the Live Data Feed. -

+ +
+
+

+ Sign up non-commercial +

+
access all MetaBrainz projects
+

+ Please be aware that misuse of the non-commercial service for + commercial purposes will result in us revoking your access token and + then billing you for your commercial use of our datasets or the Live + Data Feed. +

+ {}} + > + {({ errors, setFieldValue }) => ( +
+
+
+ +
+ {errors.csrf_token && ( +
+ {errors.csrf_token} +
+ )} +
- {}} - > - {({ errors, setFieldValue }) => ( - -
-
- +
+ Account type +
+
+ Non-commercial +
+ +
+
+ + + Username{" "} + + (public) + + + } + type="text" + name="username" + id="username" + required /> -
- {errors.csrf_token && ( -
{errors.csrf_token}
- )} -
-
-
- Account type -
-
- Non-commercial -
- -
+ -
-
- MusicBrainz Account -
-
- {mb_username} -
-
-
+ - + - -
+ +
- + - -
+ +
- -

- I agree to use the MetaBrainz data for non-commercial (less than - $500 income per year) or personal uses only. -

-

- I also agree that if I generate a Live Data Feed access token, - then I will treat my access token as a secret and will not share - this token publicly or commit it to a source code repository. -

-
+ +

+ I agree to use the MetaBrainz data for non-commercial (less + than $500 income per year) or personal uses only. +

+

+ I also agree that if I generate a Live Data Feed access + token, then I will treat my access token as a secret and + will not share this token publicly or commit it to a source + code repository. +

+
-
-
- setFieldValue("recaptcha", value)} - /> -
-
+
+
+ setFieldValue("recaptcha", value)} + /> +
+
-
-
- +
+
+
+ + )} + +
+
+
+

+ Note that any contributions you make to MusicBrainz will be + released into the Public Domain and/or licensed under a + Creative Commons by-nc-sa license. Furthermore, you give the + MetaBrainz Foundation the right to license this data for + commercial use. +
+ Please read our + {" "} + license page + {" "} + for more details. +

+

+ MusicBrainz believes strongly in the privacy of its users. Any + personal information you choose to provide will not be sold or + shared with anyone else. +
+ Please read our privacy policy for more + details. +

+

+ You may remove your personal information from our services + anytime by deleting your account. +
+ Please read our + GDPR compliance statement + {" "} + for more details. +

+

+ Creating an account on MetaBrainz will give you access to + accounts on our other services, such as ListenBrainz, + MusicBrainz, BookBrainz, and more. +
+ We do not automatically create accounts for these services + when you create a MetaBrainz account, but you will only be a + few clicks away doing so. +

+
-
- - )} - - +
+
+
+ Already have an account? Sign in +
+
+
+
+ ); } document.addEventListener("DOMContentLoaded", () => { - const { domContainer, reactProps, globalProps } = getPageProps(); + const { domContainer, reactProps } = getPageProps(); const { datasets, - mb_username, recaptcha_site_key, csrf_token, initial_form_data, @@ -194,7 +300,6 @@ document.addEventListener("DOMContentLoaded", () => { renderRoot.render( +
+
+

Create your account

+
access all MetaBrainz projects
+ {}} + > + {({ errors, setFieldValue }) => ( +
+
+
+ +
+ {errors.csrf_token && ( +
+ {errors.csrf_token} +
+ )} +
+ + + Username{" "} + + (public) + + + } + type="text" + name="username" + id="username" + required + /> + + + + + + + +
+ setFieldValue("recaptcha", value)} + /> +
+ +
+ Please note that your contributions will be released into the + public domain or licensed for use. We will never share your + personal information. +
+ +
+ + + + )} +
+
+
+
+

+ Note that any contributions you make to MusicBrainz will be + released into the Public Domain and/or licensed under a + Creative Commons by-nc-sa license. Furthermore, you give the + MetaBrainz Foundation the right to license this data for + commercial use. +
+ Please read our + {" "} + license page + {" "} + for more details. +

+

+ MusicBrainz believes strongly in the privacy of its users. Any + personal information you choose to provide will not be sold or + shared with anyone else. +
+ Please read our privacy policy for more + details. +

+

+ You may remove your personal information from our services + anytime by deleting your account. +
+ Please read our + GDPR compliance statement + {" "} + for more details. +

+

+ Creating an account on MetaBrainz will give you access to + accounts on our other services, such as ListenBrainz, + MusicBrainz, BookBrainz, and more. +
+ We do not automatically create accounts for these services + when you create a MetaBrainz account, but you will only be a + few clicks away doing so. +

+ +
+
+
+
+
+ Already have an account? Sign in +
+
+
+
+ + ); +} + +document.addEventListener("DOMContentLoaded", () => { + const { domContainer, reactProps } = getPageProps(); + const { recaptcha_site_key, csrf_token, initial_form_data, initial_errors } = + reactProps; + + const renderRoot = createRoot(domContainer!); + renderRoot.render( + + ); +}); diff --git a/frontend/js/src/forms/UserProfileEdit.tsx b/frontend/js/src/forms/UserProfileEdit.tsx new file mode 100644 index 00000000..7a3e740e --- /dev/null +++ b/frontend/js/src/forms/UserProfileEdit.tsx @@ -0,0 +1,88 @@ +import { Formik } from "formik"; +import React, { JSX } from "react"; +import { createRoot } from "react-dom/client"; +import * as Yup from "yup"; +import { getPageProps } from "../utils"; +import { TextInput } from "./utils"; + +type UserProfileEditProps = { + csrf_token: string; + initial_form_data: any; + initial_errors: any; +}; + +function UserProfileEdit({ + csrf_token, + initial_form_data, + initial_errors, +}: UserProfileEditProps): JSX.Element { + return ( + <> +

Your Profile

+

Edit contact information

+ + {}} + > + {({ errors }) => ( +
+
+
+ +
+ {errors.csrf_token && ( +
{errors.csrf_token}
+ )} +
+ + + +
+
+ +
+
+ + )} +
+ + ); +} + +document.addEventListener("DOMContentLoaded", () => { + const { domContainer, reactProps } = getPageProps(); + const { csrf_token, initial_form_data, initial_errors } = reactProps; + + const renderRoot = createRoot(domContainer!); + renderRoot.render( + + ); +}); diff --git a/frontend/js/src/forms/utils.tsx b/frontend/js/src/forms/utils.tsx index 499717bd..32ed508c 100644 --- a/frontend/js/src/forms/utils.tsx +++ b/frontend/js/src/forms/utils.tsx @@ -89,12 +89,6 @@ export function CheckboxInput({ ); } -export type Dataset = { - id: number; - name: string; - description: string; -}; - export type DatasetsProps = { datasets: Dataset[]; }; @@ -175,3 +169,109 @@ export function OAuthScopeDesc(scopes: Array) { ); } + +export type AuthCardContainerProps = { + children?: any; +}; + +export function AuthCardContainer({ children }: AuthCardContainerProps) { + return ( +
+
+
+
+ MusicBrainz +
+
+ ListenBrainz +
+
+ BookBrainz +
+
+ CritiqueBrainz +
+
+ Picard +
+
+ {children} +
+
+ ); +} + +export type AuthCardTextInputProps = JSX.IntrinsicElements["input"] & + FieldConfig & { + label: string | JSX.Element; + }; + +export function AuthCardTextInput({ + label, + children, + ...props +}: AuthCardTextInputProps) { + const [field, meta] = useField(props); + return ( +
+ +
+ + {meta.touched && meta.error ? ( +
{meta.error}
+ ) : null} +
+
+ ); +} + +export type AuthCardCheckboxInputProps = JSX.IntrinsicElements["input"] & + FieldConfig & { + label: string; + }; + +export function AuthCardCheckboxInput({ + label, + children, + ...props +}: AuthCardCheckboxInputProps) { + const [field, meta] = useField(props); + return ( +
+ + {meta.touched && meta.error ? ( +
{meta.error}
+ ) : null} +
+ ); +} diff --git a/frontend/js/src/types.d.ts b/frontend/js/src/types.d.ts index 4c98e5d8..21e64489 100644 --- a/frontend/js/src/types.d.ts +++ b/frontend/js/src/types.d.ts @@ -8,3 +8,35 @@ type Application = { description: string; website: string; }; + +type SupporterState = "active" | "rejected" | "pending" | "waiting" | "limited"; + +type Tier = { + name: string; +}; + +type Dataset = { + id: number; + name: string; + description: string; +}; + +type Supporter = { + datasets: Array; + is_commercial: boolean; + tier: Tier; + state: SupporterState; + contact_name: string; + org_name?: string; + api_url?: string; + website_url?: string; + good_standing: boolean; + token?: string; +}; + +type User = { + name: string; + email: string; + is_email_confirmed: boolean; + supporter?: Supporter; +}; diff --git a/metabrainz/__init__.py b/metabrainz/__init__.py index df8d809c..39afa368 100644 --- a/metabrainz/__init__.py +++ b/metabrainz/__init__.py @@ -1,14 +1,14 @@ import os import pprint import sys +from time import sleep -import stripe -from brainzutils.flask import CustomFlask from brainzutils import sentry +from brainzutils.flask import CustomFlask from flask import send_from_directory, request -from metabrainz.admin.quickbooks.views import QuickBooksView -from time import sleep +from flask_bcrypt import Bcrypt +from metabrainz.admin.quickbooks.views import QuickBooksView from metabrainz.utils import get_global_props # Check to see if we're running under a docker deployment. If so, don't second guess @@ -17,6 +17,8 @@ CONSUL_CONFIG_FILE_RETRY_COUNT = 10 +bcrypt = Bcrypt() + def create_app(debug=None, config_path=None): app = CustomFlask( @@ -50,16 +52,16 @@ def create_app(debug=None, config_path=None): '..', 'config.py' )) - # Load configuration files: If we're running under a docker deployment, wait until + # Load configuration files: If we're running under a docker deployment, wait until # the consul configuration is available. if deploy_env: - consul_config = os.path.join( os.path.dirname(os.path.realpath(__file__)), '..', 'consul_config.py') + consul_config = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'consul_config.py') print("loading consul %s" % consul_config) for i in range(CONSUL_CONFIG_FILE_RETRY_COUNT): if not os.path.exists(consul_config): sleep(1) - + if not os.path.exists(consul_config): print("No configuration file generated yet. Retried %d times, exiting." % CONSUL_CONFIG_FILE_RETRY_COUNT); sys.exit(-1) @@ -90,9 +92,6 @@ def create_app(debug=None, config_path=None): # Database from metabrainz import db db.init_db_engine(app.config["SQLALCHEMY_DATABASE_URI"]) - if app.config.get("SQLALCHEMY_MUSICBRAINZ_URI", None): - db.init_mb_db_engine(app.config["SQLALCHEMY_MUSICBRAINZ_URI"]) - from metabrainz import model model.db.init_app(app) @@ -104,14 +103,12 @@ def create_app(debug=None, config_path=None): from metabrainz.admin.quickbooks import quickbooks quickbooks.init(app) + # bcrypt setup + bcrypt.init_app(app) + # MusicBrainz OAuth - from metabrainz.supporter import login_manager, musicbrainz_login + from metabrainz.user import login_manager login_manager.init_app(app) - musicbrainz_login.init( - app.config['MUSICBRAINZ_BASE_URL'], - app.config['MUSICBRAINZ_CLIENT_ID'], - app.config['MUSICBRAINZ_CLIENT_SECRET'] - ) # Templates from metabrainz.utils import reformat_datetime @@ -138,7 +135,7 @@ def create_app(debug=None, config_path=None): from flask_admin import Admin from metabrainz.admin.views import HomeView - admin = Admin(app, index_view=HomeView(name='Pending supporters'), template_mode='bootstrap3') + admin = Admin(app, index_view=HomeView(name='Pending supporters')) # Models from metabrainz.model.supporter import SupporterAdminView @@ -178,10 +175,11 @@ def static_from_root(): def _register_blueprints(app): - from metabrainz.views import index_bp + from metabrainz.index.views import index_bp from metabrainz.reports.financial_reports.views import financial_reports_bp from metabrainz.reports.annual_reports.views import annual_reports_bp from metabrainz.supporter.views import supporters_bp + from metabrainz.user.views import users_bp from metabrainz.payments.views import payments_bp from metabrainz.payments.paypal.views import payments_paypal_bp from metabrainz.payments.stripe.views import payments_stripe_bp @@ -190,6 +188,7 @@ def _register_blueprints(app): app.register_blueprint(financial_reports_bp, url_prefix='/finances') app.register_blueprint(annual_reports_bp, url_prefix='/reports') app.register_blueprint(supporters_bp) + app.register_blueprint(users_bp) app.register_blueprint(payments_bp) # FIXME(roman): These URLs aren't named very correct since they receive payments diff --git a/metabrainz/admin/__init__.py b/metabrainz/admin/__init__.py index 726095fc..1fb1164d 100644 --- a/metabrainz/admin/__init__.py +++ b/metabrainz/admin/__init__.py @@ -8,11 +8,11 @@ class AuthMixin(object): """All admin views that shouldn't be available to public, must subclass this.""" def is_accessible(self): - return current_user.is_authenticated and current_user.musicbrainz_id in current_app.config['ADMINS'] + return current_user.is_authenticated and current_user.name in current_app.config['ADMINS'] def _handle_view(self, name, **kwargs): if not self.is_accessible(): - return redirect(url_for('supporters.login', next=request.url)) + return redirect(url_for('users.login', next=request.url)) class AdminBaseView(AuthMixin, BaseView): pass diff --git a/metabrainz/admin/forms.py b/metabrainz/admin/forms.py index 86896ddb..ac90d8ba 100644 --- a/metabrainz/admin/forms.py +++ b/metabrainz/admin/forms.py @@ -23,9 +23,10 @@ class SupporterEditForm(FlaskForm): # General info - musicbrainz_id = StringField("MusicBrainz Username") + username = StringField("Username") + email = EmailField("Email") + contact_name = StringField("Name") - contact_email = EmailField("Email") # Data access state = SelectField("State", choices=[ diff --git a/metabrainz/admin/views.py b/metabrainz/admin/views.py index 2a65035b..bc3a27bb 100644 --- a/metabrainz/admin/views.py +++ b/metabrainz/admin/views.py @@ -2,6 +2,7 @@ from flask import Response, request, redirect, url_for from flask_admin import expose from metabrainz.admin import AdminIndexView, AdminBaseView, forms +from metabrainz.model import db from metabrainz.model.supporter import Supporter, STATE_PENDING, STATE_ACTIVE, STATE_REJECTED, STATE_WAITING, STATE_LIMITED from metabrainz.model.token import Token from metabrainz.model.token_log import TokenLog @@ -57,9 +58,9 @@ def edit(self, supporter_id): supporter = Supporter.get(id=supporter_id) form = forms.SupporterEditForm(defaults={ - 'musicbrainz_id': supporter.musicbrainz_id, + 'username': supporter.user.name, + 'email': supporter.user.email, 'contact_name': supporter.contact_name, - 'contact_email': supporter.contact_email, 'state': supporter.state, 'is_commercial': supporter.is_commercial, 'org_name': supporter.org_name, @@ -82,9 +83,7 @@ def edit(self, supporter_id): if form.validate_on_submit(): update_data = { - 'musicbrainz_id': form.musicbrainz_id.data, 'contact_name': form.contact_name.data, - 'contact_email': form.contact_email.data, 'state': form.state.data, 'is_commercial': form.is_commercial.data, 'org_name': form.org_name.data, @@ -118,6 +117,10 @@ def edit(self, supporter_id): logging.warning(e) # Saving new one image_storage.save(os.path.join(forms.LOGO_STORAGE_DIR, logo_filename)) + + supporter.user.name = form.username.data + supporter.user.email = form.email.data + db.session.commit() db_supporter.update(supporter_id=supporter.id, **update_data) return redirect(url_for('.details', supporter_id=supporter.id)) @@ -248,6 +251,7 @@ def overview(self): token_actions=TokenLog.list(10)[0], ) + @staticmethod def dns_lookup(ip): try: @@ -298,6 +302,7 @@ def top_ips(self): days=days ) + @expose('/top-tokens/') def top_tokens(self): days = get_int_query_param('days', default=7) @@ -327,6 +332,7 @@ def token_log(self): count=count, ) + @expose('/usage') def hourly_usage_data(self): stats = AccessLog.get_hourly_usage() diff --git a/metabrainz/db/supporter.py b/metabrainz/db/supporter.py index a2977724..ea11ec8b 100644 --- a/metabrainz/db/supporter.py +++ b/metabrainz/db/supporter.py @@ -10,9 +10,7 @@ def update(supporter_id, **kwargs): multiparams = { "id": supporter_id, - "musicbrainz_id": kwargs.pop("musicbrainz_id", supporter.musicbrainz_id), "contact_name": kwargs.pop("contact_name", supporter.contact_name), - "contact_email": kwargs.pop("contact_email", supporter.contact_email), "state": kwargs.pop("state", supporter.state), "is_commercial": kwargs.pop("is_commercial", supporter.is_commercial), "org_name": kwargs.pop("org_name", supporter.org_name), @@ -39,9 +37,7 @@ def update(supporter_id, **kwargs): with db.engine.connect() as connection: connection.execute(sqlalchemy.text(""" UPDATE supporter - SET musicbrainz_id = :musicbrainz_id, - contact_name = :contact_name, - contact_email = :contact_email, + SET contact_name = :contact_name, state = :state, is_commercial = :is_commercial, org_name = :org_name, diff --git a/metabrainz/index/__init__.py b/metabrainz/index/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/metabrainz/index/forms.py b/metabrainz/index/forms.py new file mode 100644 index 00000000..fd10584e --- /dev/null +++ b/metabrainz/index/forms.py @@ -0,0 +1,75 @@ +from gettext import gettext + +from flask_wtf import FlaskForm +from wtforms import validators +from wtforms.fields import SelectMultipleField, EmailField, StringField +from wtforms.validators import DataRequired +from wtforms.widgets.core import ListWidget, CheckboxInput + + +class MeBFlaskForm(FlaskForm): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form_errors = [] + + @property + def errors(self): + errors = super().errors + if self.form_errors: + errors[None] = self.form_errors + return errors + + @property + def props_errors(self): + return {k: ". ".join(v) for k, v in self.errors.items()} + + +class DatasetsField(SelectMultipleField): + widget = ListWidget(prefix_label=False) + option_widget = CheckboxInput() + + def __init__(self, **kwargs): + super().__init__("Datasets", **kwargs, coerce=int) + + def iter_choices(self): + for dataset in self.choices: + selected = self.data is not None and dataset.id in self.data + yield dataset.id, dataset.name, selected + + def pre_validate(self, form): + if self.data: + values = list(c.id for c in self.choices) + for d in self.data: + if d not in values: + raise ValueError(self.gettext("'%(value)s' is not a valid choice for this field") % dict(value=d)) + + def post_validate(self, form, validation_stopped): + if validation_stopped: + return + datasets_dict = {self.coerce(dataset.id): dataset for dataset in self.choices} + self.data = [datasets_dict.get(x) for x in self.data] + + +class UserEditForm(MeBFlaskForm): + """ Login form for existing users. """ + email = EmailField(validators=[DataRequired(gettext("Email address is required!"))]) + + +class SupporterEditForm(UserEditForm): + """Supporter profile editing form.""" + contact_name = StringField(gettext("Name"), [ + validators.DataRequired(message=gettext("Contact name field is empty.")), + ]) + +class NonCommercialSupporterEditForm(SupporterEditForm): + datasets = DatasetsField() + + def __init__(self, available_datasets, **kwargs): + super().__init__(**kwargs) + self.datasets.choices = available_datasets + self.descriptions = {d.id: d.description for d in available_datasets} + + +class CommercialSupporterEditForm(SupporterEditForm): + pass diff --git a/metabrainz/index/views.py b/metabrainz/index/views.py new file mode 100644 index 00000000..5fd6ce43 --- /dev/null +++ b/metabrainz/index/views.py @@ -0,0 +1,237 @@ +import datetime +import json + +from flask import Blueprint, render_template, redirect, url_for, make_response +from flask_login import current_user, login_required +from flask_wtf.csrf import generate_csrf + +from metabrainz import flash +from metabrainz.index.forms import CommercialSupporterEditForm, NonCommercialSupporterEditForm, UserEditForm +from metabrainz.model import Dataset, db +from metabrainz.model.supporter import Supporter +from metabrainz.model.user import User +from metabrainz.user.email import send_verification_email + +index_bp = Blueprint('index', __name__) + + +@index_bp.route('/') +def home(): + return render_template( + 'index/index.html', + good_supporters=Supporter.get_featured(limit=4, with_logos=True), + bad_supporters=Supporter.get_featured(in_deadbeat_club=True, limit=4), + ) + + +@index_bp.route('/about') +def about(): + return render_template('index/about.html') + + +@index_bp.route('/projects') +def projects(): + return render_template('index/projects.html') + + +@index_bp.route('/team') +def team(): + return render_template('index/team.html') + + +@index_bp.route('/contact') +def contact(): + # Dear intelligent people who hate advertisers: + # No, we have no plans to add advertising, SEO, or software monetization to any of our pages. + # We are sick of being constantly harassed by advertisers, so we are giving them a place + # to send their proposals to. We're never going to read them. We're never going to respond to + # any of the proposals. And the deadline will always be extended to next month. :) + today = datetime.date.today() + today += datetime.timedelta(31) + ad_deadline = today.replace(day=1) + return render_template('index/contact.html', ad_deadline=ad_deadline) + + +@index_bp.route('/social-contract') +def social_contract(): + return render_template('index/social-contract.html') + + +@index_bp.route('/code-of-conduct') +def code_of_conduct(): + return render_template('index/code-of-conduct.html') + + +@index_bp.route('/conflict-policy') +def conflict_policy(): + return render_template('index/conflict-policy.html') + + +@index_bp.route('/sponsors') +def sponsors(): + return render_template('index/sponsors.html') + + +@index_bp.route('/bad-customers') +def bad_customers(): + return render_template( + 'index/bad-customers.html', + bad_supporters=Supporter.get_featured(in_deadbeat_club=True), + ) + + +@index_bp.route('/privacy') +def privacy_policy(): + return render_template('index/privacy.html') + + +@index_bp.route('/gdpr') +def gdpr_statement(): + return render_template('index/gdpr.html') + + +@index_bp.route('/about/customers.html') +def about_customers_redirect(): + return redirect(url_for('supporters.supporters_list'), 301) + + +@index_bp.route('/shop') +def shop(): + return render_template('index/shop.html') + + +@index_bp.route('/datasets') +def datasets(): + return render_template('index/datasets.html') + + +@index_bp.route('/datasets/postgres-dumps') +def postgres_dumps(): + return render_template('index/datasets/postgres-dumps.html') + + +@index_bp.route('/datasets/derived-dumps') +def derived_dumps(): + return render_template('index/datasets/derived-dumps.html') + + +@index_bp.route('/datasets/signup') +def signup(): + if current_user.is_authenticated: + return redirect(url_for("index.download")) + + return render_template('index/datasets/signup.html') + + +@index_bp.route('/datasets/download') +def download(): + return render_template('index/datasets/download.html') + + +@index_bp.route('/funding.json') +def funding_json(): + r = make_response(render_template('index/funding.json')) + r.mimetype = 'application/json' + return r + + +@index_bp.route('/profile') +@login_required +def profile(): + user = { + "name": current_user.name, + "email": current_user.get_email_any(), + "is_email_confirmed": current_user.is_email_confirmed() + } + + if current_user.supporter: + supporter = current_user.supporter + user["supporter"] = { + "is_commercial": supporter.is_commercial, + "state": supporter.state, + "contact_name": supporter.contact_name, + "org_name": supporter.org_name, + "website_url": supporter.website_url, + "api_url": supporter.api_url, + "datasets": [ + { + "id": d.id, + "name": d.name, + "description": d.description + } for d in supporter.datasets + ], + "good_standing": supporter.good_standing + } + if supporter.token: + user["supporter"]["token"] = supporter.token.value + else: + user["supporter"]["token"] = None + + if supporter.is_commercial: + user["supporter"]["tier"] = { + "name": supporter.tier.name + } + else: + user["supporter"]["tier"] = None + + return render_template('index/profile.html', props=json.dumps({ + "user": user, + "csrf_token": generate_csrf() if not user["is_email_confirmed"] else None, + })) + + +@index_bp.route('/profile/edit', methods=['GET', 'POST']) +@login_required +def profile_edit(): + available_datasets = [] + + if current_user.supporter: + if current_user.supporter.is_commercial: + form = CommercialSupporterEditForm() + else: + available_datasets = Dataset.query.all() + form = NonCommercialSupporterEditForm(available_datasets) + else: + form = UserEditForm() + + if form.validate_on_submit(): + if current_user.email != form.email.data: + user = User.get(email=form.email.data) + if user is None: + current_user.unconfirmed_email = form.email.data + db.session.commit() + flash.success(f"Email verification link sent to {form.email.data}") + send_verification_email( + current_user, + "Please verify your email address", + "email/user-email-address-verification.txt" + ) + else: + form.email.errors.append( + f"The given email address ({form.email.data}) is associated with a different account." + ) + + if current_user.supporter: + kwargs = { + "contact_name": form.contact_name.data, + } + if not current_user.supporter.is_commercial: + kwargs["datasets"] = form.datasets.data + current_user.supporter.update(**kwargs) + + form_data = {"email": current_user.email} + if current_user.supporter: + form_data["contact_name"] = current_user.supporter.contact_name + if current_user.supporter.is_commercial: + form_data["datasets"] = [] + else: + form_data["datasets"] = [dataset.id for dataset in current_user.datasets] + + return render_template("index/profile-edit.html", props=json.dumps({ + "datasets": [{"id": d.id, "description": d.description, "name": d.name} for d in available_datasets], + "is_supporter": current_user.supporter is not None, + "is_commercial": current_user.supporter is not None and current_user.supporter.is_commercial, + "csrf_token": generate_csrf(), + "initial_form_data": form_data, + "initial_errors": form.props_errors + })) diff --git a/metabrainz/views_test.py b/metabrainz/index/views_test.py similarity index 100% rename from metabrainz/views_test.py rename to metabrainz/index/views_test.py diff --git a/metabrainz/model/access_log.py b/metabrainz/model/access_log.py index e453b0c3..5c85556b 100644 --- a/metabrainz/model/access_log.py +++ b/metabrainz/model/access_log.py @@ -10,6 +10,8 @@ import logging import pytz +from metabrainz.model.user import User + CLEANUP_RANGE_MINUTES = 60 DIFFERENT_IP_LIMIT = 50 @@ -57,7 +59,7 @@ def create_record(cls, access_token, ip_address): .count() if count > DIFFERENT_IP_LIMIT: email = db.session \ - .query(Supporter.contact_email) \ + .query(Supporter.user.email) \ .join(Token) \ .filter(Token.value == access_token) \ .first() @@ -146,9 +148,10 @@ def top_downloaders(cls, limit=None): Returns: List of pairs """ - query = db.session.query(Supporter).join(Token).join(AccessLog) \ + query = db.session.query(func.count("AccessLog.*").label("count")) \ + .select_from(Supporter).join(Token).join(AccessLog) \ .filter(cls.timestamp > datetime.now() - timedelta(days=1)) \ - .add_columns(func.count("AccessLog.*").label("count")).group_by(Supporter.id) \ + .group_by(Supporter.id) \ .order_by(text("count DESC")) if limit: query = query.limit(limit) @@ -167,21 +170,22 @@ def top_ips(cls, days=7, limit=None): limit: Max number of items to return. Returns: - Tuple of (non_commercial, commercial) lists of [ip_address, token, musicbrainz_id, supporter_id, contact_name, contact_email] + Tuple of (non_commercial, commercial) lists of [ip_address, token, supporter_id, contact_name, email] """ query = db.session.query(AccessLog) \ .select_from(AccessLog) \ .join(Token) \ .join(Supporter) \ - .with_entities(AccessLog.ip_address, AccessLog.token, Supporter.musicbrainz_id, Supporter.id, \ - Supporter.contact_name, Supporter.contact_email, Supporter.data_usage_desc) \ + .join(User) \ + .with_entities(AccessLog.ip_address, AccessLog.token, User.name, Supporter.id, + Supporter.contact_name, User.email, Supporter.data_usage_desc) \ .filter(Supporter.is_commercial == False) \ .filter(cls.timestamp > datetime.now() - timedelta(days=days)) \ .filter(Supporter.good_standing != True) \ .add_columns(func.count("AccessLog.*").label("count")) \ - .group_by(AccessLog.ip_address, AccessLog.token, Supporter.musicbrainz_id, Supporter.id, \ - Supporter.contact_name, Supporter.contact_email, Supporter.data_usage_desc) \ + .group_by(AccessLog.ip_address, AccessLog.token, User.name, Supporter.id, + Supporter.contact_name, User.email, Supporter.data_usage_desc) \ .order_by(text("count DESC")) if limit: query = query.limit(limit) @@ -191,19 +195,20 @@ def top_ips(cls, days=7, limit=None): .select_from(AccessLog) \ .join(Token) \ .join(Supporter) \ - .with_entities(AccessLog.ip_address, AccessLog.token, Supporter.musicbrainz_id, Supporter.id, - Supporter.contact_name, Supporter.contact_email, Supporter.data_usage_desc) \ + .join(User) \ + .with_entities(AccessLog.ip_address, AccessLog.token, User.name, Supporter.id, + Supporter.contact_name, User.email, Supporter.data_usage_desc) \ .filter(Supporter.is_commercial == True) \ .filter(cls.timestamp > datetime.now() - timedelta(days=days)) \ .add_columns(func.count("AccessLog.*").label("count")) \ - .group_by(AccessLog.ip_address, AccessLog.token, Supporter.musicbrainz_id, Supporter.id, - Supporter.contact_name, Supporter.contact_email, Supporter.data_usage_desc) \ + .group_by(AccessLog.ip_address, AccessLog.token, User.name, Supporter.id, + Supporter.contact_name, User.email, Supporter.data_usage_desc) \ .order_by(text("count DESC")) if limit: query = query.limit(limit) commercial = query.all() - return (non_commercial, commercial) + return non_commercial, commercial @classmethod @@ -219,19 +224,18 @@ def top_tokens(cls, days=7, limit=None): limit: Max number of items to return. Returns: - Tuple of (non_commercial, commercial) lists of [token, musicbrainz_id, supporter_id, contact_name, contact_email] + Tuple of (non_commercial, commercial) lists of [token, username, supporter_id, contact_name, email] """ query = db.session.query(AccessLog) \ .select_from(AccessLog) \ - .join(Token).join(Supporter) \ - .with_entities(AccessLog.token, Supporter.musicbrainz_id, Supporter.id, Supporter.contact_name, \ - Supporter.contact_email) \ + .join(Token).join(Supporter).join(User) \ + .with_entities(AccessLog.token, User.name, Supporter.id, Supporter.contact_name, User.email) \ .filter(Supporter.is_commercial == False) \ .filter(cls.timestamp > datetime.now() - timedelta(days=days)) \ .filter(Supporter.good_standing != True) \ .add_columns(func.count("AccessLog.*").label("count")) \ - .group_by(AccessLog.token, Supporter.musicbrainz_id, Supporter.id, Supporter.contact_name, Supporter.contact_email) \ + .group_by(AccessLog.token, User.name, Supporter.id, Supporter.contact_name, User.email) \ .order_by(text("count DESC")) if limit: query = query.limit(limit) @@ -239,15 +243,15 @@ def top_tokens(cls, days=7, limit=None): query = db.session.query(AccessLog) \ .select_from(AccessLog) \ - .join(Token).join(Supporter) \ - .with_entities(AccessLog.token, Supporter.musicbrainz_id, Supporter.id, Supporter.contact_name, Supporter.contact_email) \ + .join(Token).join(Supporter).join(User) \ + .with_entities(AccessLog.token, User.name, Supporter.id, Supporter.contact_name, User.email) \ .filter(Supporter.is_commercial == True) \ .filter(cls.timestamp > datetime.now() - timedelta(days=days)) \ .add_columns(func.count("AccessLog.*").label("count")) \ - .group_by(AccessLog.token, Supporter.musicbrainz_id, Supporter.id, Supporter.contact_name, Supporter.contact_email) \ + .group_by(AccessLog.token, User.name, Supporter.id, Supporter.contact_name, User.email) \ .order_by(text("count DESC")) if limit: query = query.limit(limit) commercial = query.all() - return (non_commercial, commercial) + return non_commercial, commercial diff --git a/metabrainz/model/access_log_test.py b/metabrainz/model/access_log_test.py index bed08900..81bac930 100644 --- a/metabrainz/model/access_log_test.py +++ b/metabrainz/model/access_log_test.py @@ -13,7 +13,6 @@ def setUp(self): def test_access_log(self): supporter_0 = Supporter.add(is_commercial=False, musicbrainz_id="mb_test", - musicbrainz_row_id=1, contact_name="Mr. Test", contact_email="test@musicbrainz.org", data_usage_desc="poop!", @@ -26,7 +25,6 @@ def test_access_log(self): supporter_1 = Supporter.add(is_commercial=True, musicbrainz_id="mb_commercial", - musicbrainz_row_id=3, contact_name="Mr. Commercial", contact_email="testc@musicbrainz.org", data_usage_desc="poop!", diff --git a/metabrainz/model/payment.py b/metabrainz/model/payment.py index d6d49964..538abff9 100644 --- a/metabrainz/model/payment.py +++ b/metabrainz/model/payment.py @@ -3,6 +3,7 @@ from sqlalchemy import exists, text from metabrainz.model import db, Supporter +from metabrainz.model.user import User from metabrainz.payments import Currency, SUPPORTED_CURRENCIES from metabrainz.payments.receipts import send_receipt from metabrainz.admin import AdminModelView @@ -156,26 +157,14 @@ def get_biggest_donations(cls, limit=None, offset=None): return count, query.all() @staticmethod - def get_musicbrainz_row_id(editor_name): + def get_user_id(editor_name): """ Get the musicbrainz row id by given editor's name First try to retrieve the row id from the supporters table and then from MB database. """ - supporter = Supporter.get(musicbrainz_id=editor_name) - if supporter is not None and supporter.musicbrainz_row_id is not None: - return supporter.musicbrainz_row_id - - from metabrainz.db import mb_engine - if mb_engine is not None: - with mb_engine.connect() as mb_conn: - result = mb_conn.execute( - text("SELECT id FROM editor WHERE lower(name) = lower(:editor_name)"), - {"editor_name": editor_name} - ) - row = result.fetchone() - if row is not None: - return row.id - + user = User.get(name=editor_name) + if user is not None: + return user.id return None @@ -195,8 +184,6 @@ def process_paypal_ipn(cls, form): # Only processing completed donations if form['payment_status'] != 'Completed': - # TODO(roman): Convert to regular `logging.info` call when such detailed logs - # are no longer necessary to capture. logging.info("PayPal: Payment is not completed: %s",form) return @@ -266,7 +253,7 @@ def process_paypal_ipn(cls, form): if is_donation: new_payment.editor_name = form.get('custom') - new_payment.editor_id = cls.get_musicbrainz_row_id(new_payment.editor_name) + new_payment.editor_id = cls.get_user_id(new_payment.editor_name) anonymous_opt = options.get("anonymous") if anonymous_opt is None: @@ -303,10 +290,10 @@ def process_paypal_ipn(cls, form): @staticmethod def _extract_paypal_ipn_options(form: dict) -> dict: """Extracts all options from a PayPal IPN. - + This is necessary because the order or numbering of options might not what you expect it to be. - + Returns: Dictionary that maps options (by name) to their values. """ @@ -406,7 +393,7 @@ def _log_stripe_charge(cls, charge, metadata): if "editor" in metadata: new_donation.editor_name = metadata["editor"] - new_donation.editor_id = cls.get_musicbrainz_row_id(new_donation.editor_name) + new_donation.editor_id = cls.get_user_id(new_donation.editor_name) else: # Organization payment new_donation.invoice_number = metadata["invoice_number"] diff --git a/metabrainz/model/supporter.py b/metabrainz/model/supporter.py index c990ae30..e3272b92 100644 --- a/metabrainz/model/supporter.py +++ b/metabrainz/model/supporter.py @@ -1,13 +1,19 @@ +from flask_admin.contrib.sqla.form import InlineOneToOneModelConverter +from flask_admin.model import InlineFormAdmin +from sqlalchemy import ForeignKey, Integer +from sqlalchemy.orm import contains_eager, relationship, mapped_column +from sqlalchemy.orm.attributes import Mapped + from metabrainz.model import db from brainzutils.mail import send_mail from metabrainz.model.token import Token from metabrainz.admin import AdminModelView from sqlalchemy.sql.expression import func, or_ from sqlalchemy.dialects import postgresql -from flask_login import UserMixin from flask import current_app from datetime import datetime +from metabrainz.model.user import User STATE_ACTIVE = "active" STATE_PENDING = "pending" @@ -24,7 +30,7 @@ ] -class Supporter(db.Model, UserMixin): +class Supporter(db.Model): """Supporter model is used for supporters of MetaBrainz services like Live Data Feed. Supporters are either commercial or non-commercial (see `is_commercial`). Their @@ -37,8 +43,7 @@ class Supporter(db.Model, UserMixin): # Common columns used by both commercial and non-commercial supporters: id = db.Column(db.Integer, primary_key=True) is_commercial = db.Column(db.Boolean, nullable=False) - musicbrainz_id = db.Column(db.Unicode, unique=True) # MusicBrainz account that manages this supporter - musicbrainz_row_id = db.Column(db.Integer, unique=True) # MusicBrainz row id of the account + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id", ondelete="SET NULL", onupdate="CASCADE"), unique=True) created = db.Column(db.DateTime(timezone=True), default=datetime.utcnow) state = db.Column(postgresql.ENUM( STATE_ACTIVE, @@ -49,7 +54,6 @@ class Supporter(db.Model, UserMixin): name='state_types' ), nullable=False) contact_name = db.Column(db.Unicode, nullable=False) - contact_email = db.Column(db.Unicode, nullable=False) data_usage_desc = db.Column(db.UnicodeText) # Columns specific to commercial supporters: @@ -78,13 +82,15 @@ class Supporter(db.Model, UserMixin): token_log_records = db.relationship("TokenLog", back_populates="supporter", lazy="dynamic") datasets = db.relationship("Dataset", secondary="dataset_supporter") + tier = db.relationship("Tier", uselist=False, back_populates="supporters") + user: Mapped["User"] = relationship("User", uselist=False, back_populates="supporter", lazy="joined") def __str__(self): if self.is_commercial: return "%s (#%s)" % (self.org_name, self.id) else: - if self.musicbrainz_id: - return "#%s (MBID: %s)" % (self.id, self.musicbrainz_id) + if self.user.name: + return "#%s (MBID: %s)" % (self.id, self.user.name) else: return str(self.id) @@ -96,14 +102,12 @@ def token(self): def add(cls, **kwargs): new_supporter = cls( is_commercial=kwargs.pop('is_commercial'), - musicbrainz_id=kwargs.pop('musicbrainz_id'), - musicbrainz_row_id=kwargs.pop('musicbrainz_row_id'), - contact_name=kwargs.pop('contact_name'), - contact_email=kwargs.pop('contact_email'), data_usage_desc=kwargs.pop('data_usage_desc'), + user=kwargs.pop('user'), datasets=kwargs.pop('datasets', []), - org_desc=kwargs.pop('org_desc', None), + contact_name=kwargs.pop('contact_name'), + org_desc=kwargs.pop('org_desc', None), org_name=kwargs.pop('org_name', None), org_logo_url=kwargs.pop('org_logo_url', None), website_url=kwargs.pop('website_url', None), @@ -122,7 +126,6 @@ def add(cls, **kwargs): if kwargs: raise TypeError('Unexpected **kwargs: %r' % kwargs) db.session.add(new_supporter) - db.session.commit() if new_supporter.is_commercial: send_supporter_signup_notification(new_supporter) @@ -190,16 +193,18 @@ def get_active_supporters(cls): @classmethod def search(cls, value): - """Search supporters by their musicbrainz_id, org_name, contact_name, - or contact_email. - """ - query = cls.query.filter(or_( - cls.musicbrainz_id.ilike('%'+value+'%'), + """ Search supporters by their org_name, name, or email. """ + return cls.query \ + .join(Supporter.user) \ + .options(contains_eager(Supporter.user)) \ + .filter(or_( cls.org_name.ilike('%'+value+'%'), cls.contact_name.ilike('%'+value+'%'), - cls.contact_email.ilike('%'+value+'%'), - )) - return query.limit(20).all() + User.name.ilike('%'+value+'%'), + User.email.ilike('%'+value+'%'), + )) \ + .limit(20) \ + .all() def generate_token(self): """Generates new access token for this supporter.""" @@ -209,12 +214,6 @@ def generate_token(self): raise InactiveSupporterException("Can't generate token for inactive supporter.") def update(self, **kwargs): - contact_name = kwargs.pop('contact_name') - if contact_name is not None: - self.contact_name = contact_name - contact_email = kwargs.pop('contact_email') - if contact_email is not None: - self.contact_email = contact_email datasets = kwargs.pop('datasets', None) if datasets is not None: self.datasets = datasets @@ -226,7 +225,7 @@ def set_state(self, state): old_state = self.state self.state = state db.session.commit() - if old_state != self.state: + if old_state != self.state and not current_app.config["DEBUG"]: # TODO: Send additional info about new state. state_name = "ACTIVE" if self.state == STATE_ACTIVE else \ "REJECTED" if self.state == STATE_REJECTED else \ @@ -237,7 +236,7 @@ def set_state(self, state): send_mail( subject="[MetaBrainz] Your account has been updated", text='State of your MetaBrainz account has been changed to "%s".' % state_name, - recipients=[self.contact_email], + recipients=[self.user.email], ) @@ -252,7 +251,7 @@ def send_supporter_signup_notification(supporter): ('Organization name', supporter.org_name), ('Description', supporter.org_desc), ('Contact name', supporter.contact_name), - ('Contact email', supporter.contact_email), + ('Contact email', supporter.user.email), ('Website URL', supporter.website_url), ('Logo image URL', supporter.org_logo_url), @@ -277,11 +276,21 @@ class InactiveSupporterException(Exception): pass +class _InlineOneToOneModelConverter(InlineOneToOneModelConverter): + + def _calculate_mapping_key_pair(self, model, info): + return {"user": "supporter"} + + +class UserSupporterModelForm(InlineFormAdmin): + form_columns = ('name', 'email', 'unconfirmed_email', 'email_confirmed_at') + inline_converter = _InlineOneToOneModelConverter + + class SupporterAdminView(AdminModelView): column_labels = dict( id='ID', is_commercial='Commercial', - musicbrainz_id='MusicBrainz ID', data_usage_desc='Data usage description', org_desc='Organization description', good_standing='Good standing', @@ -291,7 +300,6 @@ class SupporterAdminView(AdminModelView): website_url='Organization homepage URL', api_url='Organization API page URL', contact_name='Contact name', - contact_email='Email', address_street='Street', address_city='City', address_state='State', @@ -300,6 +308,9 @@ class SupporterAdminView(AdminModelView): in_deadbeat_club='In Deadbeat Club', datasets='Datasets' ) + column_labels["user.name"] = "Username" + column_labels["user.email"] = "Email" + column_descriptions = dict( featured='Indicates if this supporter is publicly displayed on the website. ' 'If this is set, make sure to fill up information like ' @@ -312,13 +323,12 @@ class SupporterAdminView(AdminModelView): in_deadbeat_club='Indicates if this supporter refuses to support us.', ) column_list = ( - 'is_commercial', 'musicbrainz_id', 'org_name', 'tier', 'featured', + 'is_commercial', 'user.name', 'org_name', 'tier', 'featured', 'good_standing', 'state', 'datasets' ) + inline_models = (UserSupporterModelForm(User),) form_columns = ( - 'musicbrainz_id', 'contact_name', - 'contact_email', 'state', 'is_commercial', 'good_standing', diff --git a/metabrainz/model/tier.py b/metabrainz/model/tier.py index 9abc494b..709ea2b4 100644 --- a/metabrainz/model/tier.py +++ b/metabrainz/model/tier.py @@ -22,7 +22,7 @@ class Tier(db.Model): # that lists all available tiers. primary = db.Column(db.Boolean, nullable=False, default=False) - supporters = db.relationship("Supporter", backref='tier', lazy="dynamic") + supporters = db.relationship("Supporter", back_populates="tier", lazy="dynamic") def __str__(self): return "%s (#%s)" % (self.name, self.id) diff --git a/metabrainz/model/token_log.py b/metabrainz/model/token_log.py index ea358254..7b233759 100644 --- a/metabrainz/model/token_log.py +++ b/metabrainz/model/token_log.py @@ -25,7 +25,10 @@ class TokenLog(db.Model): @classmethod def create_record(cls, access_token, action): - supporter_id = current_user.id if current_user.is_authenticated else None + if current_user.is_authenticated and current_user.supporter is not None: + supporter_id = current_user.supporter.id + else: + supporter_id = None new_record = cls( token_value=access_token, action=action, diff --git a/metabrainz/model/user.py b/metabrainz/model/user.py new file mode 100644 index 00000000..2fd9c709 --- /dev/null +++ b/metabrainz/model/user.py @@ -0,0 +1,59 @@ +from flask_login import UserMixin +from sqlalchemy import Column, Integer, Identity, Text, DateTime, func, Boolean +from sqlalchemy.orm import relationship +from sqlalchemy.orm.attributes import Mapped + +from metabrainz.model import db + + +class User(db.Model, UserMixin): + __tablename__ = "user" + + id = Column(Integer, Identity(), primary_key=True) + name = Column(Text, nullable=False) # TODO: add a uniqueness constraint maybe in conjunction with the deleted field + password = Column(Text, nullable=False) # TODO: add a constraint to ensure password is cleared when deleted field is set + + email = Column(Text, unique=True) # TODO: check if unique should be only deleted = false + unconfirmed_email = Column(Text) + email_confirmed_at = Column(DateTime(timezone=True)) + + member_since = Column(DateTime(timezone=True), default=func.now()) + last_login_at = Column(DateTime(timezone=True), default=func.now()) + last_updated = Column(DateTime(timezone=True), default=func.now()) + + deleted = Column(Boolean, default=False) + + supporter: Mapped["Supporter"] = relationship("Supporter", uselist=False, back_populates="user", lazy="joined") + + def get_user_id(self): + return self.id + + def get_email_any(self): + return self.email or self.unconfirmed_email + + def is_email_confirmed(self): + return self.email is not None + + @classmethod + def add(cls, **kwargs): + from metabrainz import bcrypt + + password = kwargs.pop("password") + password_hash = bcrypt.generate_password_hash(password).decode("utf-8") + new_user = cls(name=kwargs.pop("name"), password=password_hash, unconfirmed_email=kwargs.pop("unconfirmed_email")) + if kwargs: + raise TypeError("Unexpected **kwargs: %r" % (kwargs,)) + db.session.add(new_user) + return new_user + + @classmethod + def get(cls, **kwargs): + row = cls.query.filter_by(**kwargs).first() + if row: + return row + + if "email" in kwargs: + kwargs["unconfirmed_email"] = kwargs.pop("email") + return cls.query.filter_by(**kwargs).first() + + return None diff --git a/metabrainz/payments/views.py b/metabrainz/payments/views.py index cd2c2c51..2465c752 100644 --- a/metabrainz/payments/views.py +++ b/metabrainz/payments/views.py @@ -31,7 +31,7 @@ def donate(): form.editor.data = editor else: if current_user is not None and not current_user.is_anonymous: - form.editor.data = current_user.musicbrainz_id + form.editor.data = current_user.name amount = None if _amount := request.args.get('amount'): diff --git a/metabrainz/supporter/__init__.py b/metabrainz/supporter/__init__.py index 54325828..e69de29b 100644 --- a/metabrainz/supporter/__init__.py +++ b/metabrainz/supporter/__init__.py @@ -1,22 +0,0 @@ -from flask import redirect, url_for -from flask_login import LoginManager, current_user -from metabrainz.model.supporter import Supporter -from functools import wraps - -login_manager = LoginManager() -login_manager.login_view = 'supporters.login' - - -@login_manager.user_loader -def load_supporter(supporter_id): - return Supporter.get(id=supporter_id) - - -def login_forbidden(f): - @wraps(f) - def decorated(*args, **kwargs): - if current_user.is_anonymous is False: - return redirect(url_for('supporters.profile')) - return f(*args, **kwargs) - - return decorated diff --git a/metabrainz/supporter/forms.py b/metabrainz/supporter/forms.py index ff1904c6..2a11cf1f 100644 --- a/metabrainz/supporter/forms.py +++ b/metabrainz/supporter/forms.py @@ -1,45 +1,20 @@ from flask_wtf import FlaskForm, RecaptchaField from flask_babel import gettext from wtforms import BooleanField, TextAreaField, validators -from wtforms.fields import StringField, SelectMultipleField, EmailField, URLField, DecimalField +from wtforms.fields import StringField, EmailField, URLField, DecimalField from wtforms.validators import DataRequired, Length -from wtforms.widgets import ListWidget, CheckboxInput +from metabrainz.index.forms import DatasetsField +from metabrainz.user.forms import UserSignupForm -class DatasetsField(SelectMultipleField): - widget = ListWidget(prefix_label=False) - option_widget = CheckboxInput() - def __init__(self, **kwargs): - super().__init__("Datasets", **kwargs, coerce=int) - - def iter_choices(self): - for dataset in self.choices: - selected = self.data is not None and dataset.id in self.data - yield dataset.id, dataset.name, selected - - def pre_validate(self, form): - if self.data: - values = list(c.id for c in self.choices) - for d in self.data: - if d not in values: - raise ValueError(self.gettext("'%(value)s' is not a valid choice for this field") % dict(value=d)) - - def post_validate(self, form, validation_stopped): - if validation_stopped: - return - datasets_dict = {self.coerce(dataset.id): dataset for dataset in self.choices} - self.data = [datasets_dict.get(x) for x in self.data] - - -class SupporterSignUpForm(FlaskForm): +class SupporterSignUpForm(UserSignupForm): """Base sign up form for new supporters. Contains common fields required from both commercial and non-commercial supporters. """ contact_name = StringField(validators=[DataRequired(gettext("Contact name is required!"))]) - contact_email = EmailField(validators=[DataRequired(gettext("Email address is required!"))]) usage_desc = TextAreaField( gettext("Can you please tell us more about the project in which you'd like to use our data? Do you plan to self host the data or use our APIs?"), validators=[ @@ -50,17 +25,13 @@ class SupporterSignUpForm(FlaskForm): agreement = BooleanField(validators=[DataRequired(message=gettext("You need to accept the agreement!"))]) recaptcha = RecaptchaField() - def __init__(self, default_email=None, **kwargs): - kwargs.setdefault('contact_email', default_email) - FlaskForm.__init__(self, **kwargs) - class NonCommercialSignUpForm(SupporterSignUpForm): """Sign up form for non-commercial supporters.""" datasets = DatasetsField() - def __init__(self, available_datasets, default_email=None, **kwargs): - super().__init__(default_email, **kwargs) + def __init__(self, available_datasets, **kwargs): + super().__init__(**kwargs) self.datasets.choices = available_datasets self.descriptions = {d.id: d.description for d in available_datasets} @@ -97,28 +68,3 @@ class CommercialSignUpForm(SupporterSignUpForm): ]) amount_pledged = DecimalField() - - -class SupporterEditForm(FlaskForm): - """Supporter profile editing form.""" - contact_name = StringField(gettext("Name"), [ - validators.DataRequired(message=gettext("Contact name field is empty.")), - ]) - contact_email = EmailField(gettext("Email"), [ - validators.Optional(strip_whitespace=False), - validators.Email(message=gettext("Email field is not a valid email address.")), - validators.DataRequired(message=gettext("Contact email field is empty.")), - ]) - - -class NonCommercialSupporterEditForm(SupporterEditForm): - datasets = DatasetsField() - - def __init__(self, available_datasets, **kwargs): - super().__init__(**kwargs) - self.datasets.choices = available_datasets - self.descriptions = {d.id: d.description for d in available_datasets} - - -class CommercialSupporterEditForm(SupporterEditForm): - pass \ No newline at end of file diff --git a/metabrainz/supporter/musicbrainz_login.py b/metabrainz/supporter/musicbrainz_login.py deleted file mode 100644 index d2799e82..00000000 --- a/metabrainz/supporter/musicbrainz_login.py +++ /dev/null @@ -1,67 +0,0 @@ -from rauth import OAuth2Service -from flask import request, url_for, current_app -from metabrainz import session -from metabrainz.utils import generate_string -import json - -_musicbrainz_service = None - - -def init(base_url, client_id, client_secret): - global _musicbrainz_service - _musicbrainz_service = OAuth2Service( - name='musicbrainz', - base_url=base_url, - authorize_url=base_url+"oauth2/authorize", - access_token_url=base_url+"oauth2/token", - client_id=client_id, - client_secret=client_secret, - ) - - -def get_supporter(authorization_code): - """Fetches info about current supporter. - - Returns: - MusicBrainz username and email address. - """ - s = _musicbrainz_service.get_auth_session(data={ - 'code': authorization_code, - 'grant_type': 'authorization_code', - 'redirect_uri': url_for( - 'supporters.musicbrainz_post', - _external=True, - _scheme=current_app.config['PREFERRED_URL_SCHEME'], - ) - }, decoder=lambda content: json.loads(content.decode("utf-8"))) - data = s.get('oauth2/userinfo').json() - return data.get('sub'), data.get('email'), data.get("metabrainz_user_id") - - -def get_authentication_uri(): - """Prepare and return URL to authentication service login form.""" - csrf = generate_string(20) - session.persist_data(csrf=csrf) - params = { - 'response_type': 'code', - 'redirect_uri': url_for( - 'supporters.musicbrainz_post', - _external=True, - _scheme=current_app.config['PREFERRED_URL_SCHEME'], - ), - 'scope': 'profile email', - 'state': csrf, - } - return _musicbrainz_service.get_authorize_url(**params) - - -def validate_post_login(): - """Function validating parameters passed after redirection from login form. - Should return True, if everything is ok, or False, if something went wrong. - """ - if request.args.get('error'): - return False - # TODO(roman): Maybe check if both are there: - if session.fetch_data('csrf') != request.args.get('state'): - return False - return True diff --git a/metabrainz/supporter/views.py b/metabrainz/supporter/views.py index d3ae6ba3..3e58918b 100644 --- a/metabrainz/supporter/views.py +++ b/metabrainz/supporter/views.py @@ -1,21 +1,20 @@ import json -import logging from flask import Blueprint, request, redirect, render_template, url_for, jsonify, current_app from flask_babel import gettext -from flask_login import login_user, logout_user, login_required, current_user +from flask_login import login_user, login_required, current_user from flask_wtf.csrf import generate_csrf -from werkzeug.exceptions import NotFound, InternalServerError, BadRequest +from werkzeug.exceptions import NotFound, BadRequest from metabrainz import flash, session -from brainzutils.mail import send_mail, MailException -from metabrainz.model import Dataset +from metabrainz.model import Dataset, db from metabrainz.model.supporter import Supporter, InactiveSupporterException from metabrainz.model.tier import Tier from metabrainz.model.token import TokenGenerationLimitException -from metabrainz.supporter import musicbrainz_login, login_forbidden -from metabrainz.supporter.forms import CommercialSignUpForm, NonCommercialSignUpForm, CommercialSupporterEditForm, \ - NonCommercialSupporterEditForm +from metabrainz.model.user import User +from metabrainz.user import login_forbidden +from metabrainz.supporter.forms import CommercialSignUpForm, NonCommercialSignUpForm +from metabrainz.user.email import send_verification_email supporters_bp = Blueprint('supporters', __name__) @@ -23,7 +22,6 @@ SESSION_KEY_TIER_ID = 'account_tier' SESSION_KEY_MB_USERNAME = 'mb_username' SESSION_KEY_MB_EMAIL = 'mb_email' -SESSION_KEY_MB_ROW_ID = 'mb_row_id' ACCOUNT_TYPE_COMMERCIAL = 'commercial' ACCOUNT_TYPE_NONCOMMERCIAL = 'noncommercial' @@ -56,29 +54,6 @@ def tier(tier_id): return render_template('supporters/tier.html', tier=t) -@supporters_bp.route('/signup') -@login_forbidden -def signup(): - mb_username = session.fetch_data(SESSION_KEY_MB_USERNAME) - if mb_username is None: - # Show template with a link to MusicBrainz OAuth page - return render_template('supporters/mb-signup.html') - - account_type = session.fetch_data(SESSION_KEY_ACCOUNT_TYPE) - if not account_type: - flash.info(gettext("Please select account type to sign up.")) - return redirect(url_for(".account_type")) - - if account_type == ACCOUNT_TYPE_COMMERCIAL: - tier_id = session.fetch_data(SESSION_KEY_TIER_ID) - if not tier_id: - flash.info(gettext("Please select account type to sign up.")) - return redirect(url_for(".account_type")) - return redirect(url_for(".signup_commercial", tier_id=tier_id)) - else: - return redirect(url_for(".signup_noncommercial")) - - @supporters_bp.route('/signup/commercial', methods=('GET', 'POST')) @login_forbidden def signup_commercial(): @@ -87,13 +62,16 @@ def signup_commercial(): Commercial supporters need to choose support tier before filling out the form. `tier_id` argument with ID of a tier of choice is required there. """ + csrf_token = generate_csrf() + recaptcha_site_key = current_app.config["RECAPTCHA_PUBLIC_KEY"] + tier_id = request.args.get('tier_id') if not tier_id: flash.warning(gettext("You need to choose support tier before signing up!")) return redirect(url_for('.account_type')) try: - tier_id = int(tier_id) + tier_id = int(tier_id) except ValueError: tier_id = 0 @@ -101,20 +79,16 @@ def signup_commercial(): if not selected_tier or not selected_tier.available: flash.error(gettext("You need to choose existing tier before signing up!")) return redirect(url_for(".account_type")) + _tier = { + "name": selected_tier.name, + "price": float(selected_tier.price) + } - mb_username = session.fetch_data(SESSION_KEY_MB_USERNAME) - if not mb_username: - session.persist_data(**{ - SESSION_KEY_ACCOUNT_TYPE: ACCOUNT_TYPE_COMMERCIAL, - SESSION_KEY_TIER_ID: selected_tier.id, - }) - return redirect(url_for(".signup")) - mb_email = session.fetch_data(SESSION_KEY_MB_EMAIL) - - mb_row_id = session.fetch_data(SESSION_KEY_MB_ROW_ID) - mb_row_id = int(mb_row_id) if mb_row_id else None - - form = CommercialSignUpForm(default_email=mb_email) + form = CommercialSignUpForm() + form_data = dict(**form.data) + if form_data["amount_pledged"]: + form_data["amount_pledged"] = float(form_data["amount_pledged"]) + form_data.pop("csrf_token", None) def custom_validation(f): if f.amount_pledged.data < selected_tier.price: @@ -126,75 +100,72 @@ def custom_validation(f): return True if form.validate_on_submit() and custom_validation(form): - # Checking if this supporter already exists - new_supporter = Supporter.get(musicbrainz_id=mb_username) - if not new_supporter: - new_supporter = Supporter.add( - is_commercial=True, - musicbrainz_id=mb_username, - musicbrainz_row_id=mb_row_id, - contact_name=form.contact_name.data, - contact_email=form.contact_email.data, - data_usage_desc=form.usage_desc.data, - - org_name=form.org_name.data, - org_desc=form.org_desc.data, - website_url=form.website_url.data, - org_logo_url=form.logo_url.data, - api_url=form.api_url.data, - - address_street=form.address_street.data, - address_city=form.address_city.data, - address_state=form.address_state.data, - address_postcode=form.address_postcode.data, - address_country=form.address_country.data, - - tier_id=tier_id, - amount_pledged=form.amount_pledged.data, - datasets=[] - ) - flash.success(gettext( - "Thanks for signing up! Your application will be reviewed " - "soon. We will send you updates via email." - )) - try: - send_mail( - subject="[MetaBrainz] Sign up confirmation", - text='Dear %s,\n\nThank you for signing up!\n\nYour application' - ' will be reviewed soon and we will send you updates via email.' - ' Please know that you may use our APIs and static data dumps for' - ' evaluation purposes while your application is pending. You do not' - ' need any API keys to do this.\n\n-- The MetaBrainz Team' - % new_supporter.contact_name, - recipients=[new_supporter.contact_email], - ) - except MailException as e: - logging.error(e) - flash.warning(gettext( - "Failed to send welcome email to you. We are looking into it. " - "Sorry for inconvenience!" - )) - else: - flash.info(gettext("You already have a MetaBrainz account!")) - login_user(new_supporter) - return redirect(url_for('.profile')) - - form_errors = {k: ". ".join(v) for k, v in form.errors.items()} - form_data = dict(**form.data) - if form_data["amount_pledged"] is not None: - form_data["amount_pledged"] = float(form_data["amount_pledged"]) - form_data.pop("csrf_token", None) + user = User.get(name=form.username.data) + if user is not None: + form.username.errors.append(f"Another user with username '{form.username.data}' exists.") + return render_template("supporters/signup-commercial.html", props=json.dumps({ + "tier": _tier, + "recaptcha_site_key": recaptcha_site_key, + "csrf_token": csrf_token, + "initial_form_data": form_data, + "initial_errors": form.props_errors + })) + + # TODO: Handle the case where multiple users sign up with same email but haven"t verified it yet + user = User.get(email=form.email.data) + if user is not None: + form.email.errors.append(f"Another user with email '{form.email.data}' exists.") + return render_template("supporters/signup-commercial.html", props=json.dumps({ + "tier": _tier, + "recaptcha_site_key": recaptcha_site_key, + "csrf_token": csrf_token, + "initial_form_data": form_data, + "initial_errors": form.props_errors + })) + + user = User.add(name=form.username.data, unconfirmed_email=form.email.data, password=form.password.data) + Supporter.add( + is_commercial=True, + contact_name=form.contact_name.data, + data_usage_desc=form.usage_desc.data, + + org_name=form.org_name.data, + org_desc=form.org_desc.data, + website_url=form.website_url.data, + org_logo_url=form.logo_url.data, + api_url=form.api_url.data, + + address_street=form.address_street.data, + address_city=form.address_city.data, + address_state=form.address_state.data, + address_postcode=form.address_postcode.data, + address_country=form.address_country.data, + + tier_id=tier_id, + amount_pledged=form.amount_pledged.data, + datasets=[], + user=user + ) + db.session.commit() + + flash.success(gettext( + "Thanks for signing up! Your application will be reviewed " + "soon. We will send you updates via email." + )) + send_verification_email( + user, + "[MetaBrainz] Sign up confirmation", + "email/supporter-commercial-welcome-email-address-verification.txt" + ) + login_user(user) + return redirect(url_for('index.profile')) return render_template("supporters/signup-commercial.html", props=json.dumps({ - "tier": { - "name": selected_tier.name, - "price": float(selected_tier.price) - }, - "mb_username": mb_username, - "recaptcha_site_key": current_app.config["RECAPTCHA_PUBLIC_KEY"], - "csrf_token": generate_csrf(), + "tier": _tier, + "recaptcha_site_key": recaptcha_site_key, + "csrf_token": csrf_token, "initial_form_data": form_data, - "initial_errors": form_errors + "initial_errors": form.props_errors })) @@ -202,172 +173,76 @@ def custom_validation(f): @login_forbidden def signup_noncommercial(): """Sign up endpoint for non-commercial supporters.""" - mb_username = session.fetch_data(SESSION_KEY_MB_USERNAME) - if not mb_username: - session.persist_data(**{ - SESSION_KEY_ACCOUNT_TYPE: ACCOUNT_TYPE_NONCOMMERCIAL, - }) - return redirect(url_for(".signup")) - mb_email = session.fetch_data(SESSION_KEY_MB_EMAIL) - - mb_row_id = session.fetch_data(SESSION_KEY_MB_ROW_ID) - mb_row_id = int(mb_row_id) if mb_row_id else None - - available_datasets = Dataset.query.all() - - form = NonCommercialSignUpForm(available_datasets, default_email=mb_email) - if form.validate_on_submit(): - # Checking if this supporter already exists - new_supporter = Supporter.get(musicbrainz_id=mb_username) - if not new_supporter: - new_supporter = Supporter.add( - is_commercial=False, - musicbrainz_id=mb_username, - musicbrainz_row_id=mb_row_id, - contact_name=form.contact_name.data, - contact_email=form.contact_email.data, - data_usage_desc=form.usage_desc.data, - datasets=form.datasets.data - ) - flash.success(gettext("Thanks for signing up!")) - try: - send_mail( - subject="[MetaBrainz] Sign up confirmation", - text='Dear %s,\n\nThank you for signing up!\n\nYou can now generate ' - 'an access token for the MetaBrainz API on your profile page.' - % new_supporter.contact_name, - recipients=[new_supporter.contact_email], - ) - except MailException as e: - logging.error(e) - flash.warning(gettext( - "Failed to send welcome email to you. We are looking into it. " - "Sorry for inconvenience!" - )) - else: - flash.info(gettext("You already have a MetaBrainz account!")) - login_user(new_supporter) - return redirect(url_for('.profile')) - - form_errors = {k: ". ".join(v) for k, v in form.errors.items()} + available_datasets = [ + {"id": d.id, "description": d.description, "name": d.name} + for d in Dataset.query.all() + ] + csrf_token = generate_csrf() + recaptcha_site_key = current_app.config["RECAPTCHA_PUBLIC_KEY"] + + form = NonCommercialSignUpForm(available_datasets) form_data = dict(**form.data) form_data.pop("csrf_token", None) - return render_template("supporters/signup-non-commercial.html", props=json.dumps({ - "datasets": [{"id": d.id, "description": d.description, "name": d.name} for d in available_datasets], - "mb_username": mb_username, - "recaptcha_site_key": current_app.config["RECAPTCHA_PUBLIC_KEY"], - "csrf_token": generate_csrf(), - "initial_form_data": form_data, - "initial_errors": form_errors - })) - - -@supporters_bp.route('/login/musicbrainz') -@login_forbidden -def musicbrainz(): - session.session['next'] = request.args.get('next') - return redirect(musicbrainz_login.get_authentication_uri()) - - -@supporters_bp.route('/login/musicbrainz/post') -@login_forbidden -def musicbrainz_post(): - """MusicBrainz OAuth2 callback endpoint.""" - if not musicbrainz_login.validate_post_login(): - raise BadRequest(gettext("Login failed!")) - code = request.args.get('code') - if not code: - raise InternalServerError(gettext("Authorization code is missing!")) - - try: - mb_username, mb_email, mb_row_id = musicbrainz_login.get_supporter(code) - except KeyError: - raise BadRequest(gettext("Login failed!")) - - session.persist_data(**{ - SESSION_KEY_MB_USERNAME: mb_username, - SESSION_KEY_MB_EMAIL: mb_email, - SESSION_KEY_MB_ROW_ID: mb_row_id - }) - supporter = Supporter.get(musicbrainz_id=mb_username) - if supporter: # Checking if supporter is already signed up - login_user(supporter) - next = session.session.get('next') - return redirect(next) if next else redirect(url_for('.profile')) - else: - flash.info("This is the first time you've signed into metabrainz.org, please sign up!") - return redirect(url_for('.signup')) - - -@supporters_bp.route('/profile') -@login_required -def profile(): - return render_template("supporters/profile.html") - - -@supporters_bp.route('/profile/edit', methods=['GET', 'POST']) -@login_required -def profile_edit(): - if current_user.is_commercial: - available_datasets = [] - form = CommercialSupporterEditForm() - else: - available_datasets = Dataset.query.all() - form = NonCommercialSupporterEditForm(available_datasets) - if form.validate_on_submit(): - kwargs = { - "contact_name": form.contact_name.data, - "contact_email": form.contact_email.data - } - - if not current_user.is_commercial: - kwargs["datasets"] = form.datasets.data + user = User.get(name=form.username.data) + if user is not None: + form.username.errors.append(f"Another user with username '{form.username.data}' exists.") + return render_template("supporters/signup-non-commercial.html", props=json.dumps({ + "datasets": available_datasets, + "recaptcha_site_key": recaptcha_site_key, + "csrf_token": csrf_token, + "initial_form_data": form_data, + "initial_errors": form.props_errors + })) + + # TODO: Handle the case where multiple users sign up with same email but haven"t verified it yet + user = User.get(email=form.email.data) + if user is not None: + form.email.errors.append(f"Another user with email '{form.email.data}' exists.") + return render_template("supporters/signup-non-commercial.html", props=json.dumps({ + "datasets": available_datasets, + "recaptcha_site_key": recaptcha_site_key, + "csrf_token": csrf_token, + "initial_form_data": form_data, + "initial_errors": form.props_errors + })) + + user = User.add(name=form.username.data, unconfirmed_email=form.email.data, password=form.password.data) + Supporter.add( + is_commercial=False, + contact_name=form.contact_name.data, + data_usage_desc=form.usage_desc.data, + datasets=form.datasets.data, + user=user + ) + db.session.commit() + + send_verification_email( + user, + "[MetaBrainz] Sign up confirmation", + "email/supporter-noncommercial-welcome-email-address-verification.txt" + ) + flash.success(gettext("Thanks for signing up! Please check your inbox to complete verification.")) + + login_user(user) + return redirect(url_for('index.profile')) - current_user.update(**kwargs) - flash.success("Profile updated.") - return redirect(url_for('.profile')) - else: - form.contact_name.data = current_user.contact_name - form.contact_email.data = current_user.contact_email - - if not current_user.is_commercial and current_user.datasets: - form.datasets.data = [dataset.id for dataset in current_user.datasets] - - form_errors = {k: ". ".join(v) for k, v in form.errors.items()} - form_data = dict(**form.data) - form_data.pop("csrf_token", None) - - return render_template("supporters/profile-edit.html", props=json.dumps({ - "datasets": [{"id": d.id, "description": d.description, "name": d.name} for d in available_datasets], - "is_commercial": current_user.is_commercial, - "csrf_token": generate_csrf(), + return render_template("supporters/signup-non-commercial.html", props=json.dumps({ + "datasets": available_datasets, + "recaptcha_site_key": recaptcha_site_key, + "csrf_token": csrf_token, "initial_form_data": form_data, - "initial_errors": form_errors + "initial_errors": form.props_errors })) -@supporters_bp.route('/profile/regenerate-token', methods=['POST']) +@supporters_bp.route('/supporters/profile/regenerate-token', methods=['POST']) @login_required def regenerate_token(): try: - return jsonify({'token': current_user.generate_token()}) + return jsonify({'token': current_user.supporter.generate_token()}) except InactiveSupporterException: raise BadRequest(gettext("Can't generate new token unless account is active.")) except TokenGenerationLimitException as e: - return jsonify({'error': e.message}), 429 # https://tools.ietf.org/html/rfc6585#page-3 - - -@supporters_bp.route('/login') -@login_forbidden -def login(): - return render_template('supporters/mb-login.html') - - -@supporters_bp.route('/logout') -@login_required -def logout(): - logout_user() - session.clear() - return redirect(url_for('index.home')) + return jsonify({'error': e.args[0]}), 429 # https://tools.ietf.org/html/rfc6585#page-3 diff --git a/metabrainz/supporter/views_test.py b/metabrainz/supporter/views_test.py index fbbddfc9..4fbe3d72 100644 --- a/metabrainz/supporter/views_test.py +++ b/metabrainz/supporter/views_test.py @@ -67,20 +67,14 @@ def test_musicbrainz_post(self): self.assert400(client.get(url_for('supporters.musicbrainz_post', state="fake"))) def test_profile(self): - self.assertStatus(self.client.get(url_for('supporters.profile')), 302) + self.assertStatus(self.client.get(url_for('index.profile')), 302) def test_profile_edit(self): - self.assertStatus(self.client.get(url_for('supporters.profile_edit')), 302) + self.assertStatus(self.client.get(url_for('index.profile_edit')), 302) def test_regenerate_token(self): self.assertStatus(self.client.post(url_for('supporters.regenerate_token')), 302) self.assertStatus(self.client.get(url_for('supporters.regenerate_token')), 405) - def test_login(self): - self.assert200(self.client.get(url_for('supporters.login'))) - - def test_logout(self): - self.assertStatus(self.client.get(url_for('supporters.logout')), 302) - def test_bad_standing(self): self.assert200(self.client.get(url_for('supporters.bad_standing'))) diff --git a/metabrainz/templates/admin/commercial-users/index.html b/metabrainz/templates/admin/commercial-supporters/index.html similarity index 96% rename from metabrainz/templates/admin/commercial-users/index.html rename to metabrainz/templates/admin/commercial-supporters/index.html index c48df3e8..4d6c6ceb 100644 --- a/metabrainz/templates/admin/commercial-users/index.html +++ b/metabrainz/templates/admin/commercial-supporters/index.html @@ -22,7 +22,7 @@

Commercial supporters

{{ supporter.org_name }} - ({{ supporter.musicbrainz_id }}) + ({{ supporter.user.name }}) @@ -37,7 +37,7 @@

Commercial supporters

{% endif %} {{ supporter.contact_name }} - {{ supporter.contact_email }} + {{ supporter.user.email }} {% if supporter.good_standing %} Good standing diff --git a/metabrainz/templates/admin/home.html b/metabrainz/templates/admin/home.html index 57216c0d..1ed25bc3 100644 --- a/metabrainz/templates/admin/home.html +++ b/metabrainz/templates/admin/home.html @@ -6,10 +6,10 @@

Welcome!

- + - + @@ -18,8 +18,8 @@

Welcome!

- - + + diff --git a/metabrainz/templates/admin/master.html b/metabrainz/templates/admin/master.html index c028c1f9..6398ee07 100644 --- a/metabrainz/templates/admin/master.html +++ b/metabrainz/templates/admin/master.html @@ -1,8 +1,7 @@ {% extends admin_base_template %} {% block head_css %} - {# The css file has a .less extension in the manifest file entry (due to its original name in Webpack entry) #} - + {{ super() }}
NameOrganization Name Contact name Contact emailMusicBrainz IDUsername Tier Applied on
{{ supporter.org_name }} {{ supporter.contact_name }}{{ supporter.contact_email }}{{ supporter.musicbrainz_id }}{{ supporter.user.email }}{{ supporter.user.name }} {{ supporter.tier }} {{ supporter.created }}