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/schema_updates/2025-02-15-add-moderation-log.sql b/admin/schema_updates/2025-02-15-add-moderation-log.sql
new file mode 100644
index 00000000..84936f60
--- /dev/null
+++ b/admin/schema_updates/2025-02-15-add-moderation-log.sql
@@ -0,0 +1,22 @@
+CREATE TYPE moderation_action_type AS ENUM ('block', 'unblock');
+
+BEGIN;
+
+ALTER TABLE "user" ADD COLUMN is_blocked BOOLEAN NOT NULL DEFAULT FALSE;
+CREATE TABLE moderation_log (
+ id INTEGER GENERATED BY DEFAULT AS IDENTITY,
+ user_id INTEGER NOT NULL,
+ moderator_id INTEGER NOT NULL,
+ action moderation_action_type NOT NULL,
+ reason TEXT NOT NULL,
+ timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
+);
+ALTER TABLE moderation_log ADD CONSTRAINT moderation_log_pkey PRIMARY KEY (id);
+ALTER TABLE moderation_log ADD CONSTRAINT moderation_log_user_id_fkey FOREIGN KEY (user_id) REFERENCES "user" (id);
+ALTER TABLE moderation_log ADD CONSTRAINT moderation_log_moderator_id_fkey FOREIGN KEY (moderator_id) REFERENCES "user" (id);
+CREATE INDEX moderation_log_user_id_idx ON moderation_log (user_id);
+
+ALTER TABLE "user" ADD COLUMN login_id UUID NOT NULL;
+CREATE UNIQUE INDEX user_login_id_idx ON "user" (login_id);
+
+COMMIT;
diff --git a/admin/schema_updates/2025-add-nonce-to-authorization-code.sql b/admin/schema_updates/2025-07-01-add-nonce-to-authorization-code.sql
similarity index 100%
rename from admin/schema_updates/2025-add-nonce-to-authorization-code.sql
rename to admin/schema_updates/2025-07-01-add-nonce-to-authorization-code.sql
diff --git a/admin/schema_updates/2025-09-18-add-moderation-action-types.sql b/admin/schema_updates/2025-09-18-add-moderation-action-types.sql
new file mode 100644
index 00000000..373dea6c
--- /dev/null
+++ b/admin/schema_updates/2025-09-18-add-moderation-action-types.sql
@@ -0,0 +1,2 @@
+ALTER TYPE moderation_action_type ADD VALUE 'comment';
+ALTER TYPE moderation_action_type ADD VALUE 'verify_email';
diff --git a/admin/sql/create_foreign_keys.sql b/admin/sql/create_foreign_keys.sql
index b0fc6ca8..202215a7 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
@@ -17,7 +21,7 @@ ALTER TABLE dataset_supporter
ALTER TABLE dataset_supporter
ADD CONSTRAINT dataset_supporter_dataset_id_fkey FOREIGN KEY (dataset_id)
- REFERENCES "dataset" (id) MATCH SIMPLE
+ REFERENCES dataset (id) MATCH SIMPLE
ON UPDATE NO ACTION ON DELETE NO ACTION;
ALTER TABLE token_log
@@ -35,4 +39,14 @@ ALTER TABLE access_log
REFERENCES token (value) MATCH SIMPLE
ON UPDATE NO ACTION ON DELETE NO ACTION;
+ALTER TABLE moderation_log
+ ADD CONSTRAINT moderation_log_user_id_fkey FOREIGN KEY (user_id)
+ REFERENCES "user" (id) MATCH SIMPLE
+ ON UPDATE CASCADE ON DELETE SET NULL;
+
+ALTER TABLE moderation_log
+ ADD CONSTRAINT moderation_log_moderator_id_fkey FOREIGN KEY (moderator_id)
+ REFERENCES "user" (id) MATCH SIMPLE
+ ON UPDATE CASCADE ON DELETE SET NULL;
+
COMMIT;
diff --git a/admin/sql/create_indexes.sql b/admin/sql/create_indexes.sql
index be997d2e..acd9b057 100644
--- a/admin/sql/create_indexes.sql
+++ b/admin/sql/create_indexes.sql
@@ -2,4 +2,6 @@ BEGIN;
-- TODO: Add some, if needed.
+CREATE INDEX moderation_log_user_id_idx ON moderation_log (user_id);
+
COMMIT;
diff --git a/admin/sql/create_primary_keys.sql b/admin/sql/create_primary_keys.sql
index 0cc42a1f..03ec1294 100644
--- a/admin/sql/create_primary_keys.sql
+++ b/admin/sql/create_primary_keys.sql
@@ -1,5 +1,7 @@
BEGIN;
+ALTER TABLE "user" ADD CONSTRAINT user_pkey PRIMARY KEY (id);
+ALTER TABLE moderation_log ADD CONSTRAINT moderation_log_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..5cf8ccb5 100644
--- a/admin/sql/create_tables.sql
+++ b/admin/sql/create_tables.sql
@@ -1,5 +1,29 @@
BEGIN;
+CREATE TABLE "user" (
+ id INTEGER GENERATED BY DEFAULT AS IDENTITY,
+ login_id UUID NOT NULL,
+ 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 NOT NULL DEFAULT FALSE,
+ is_blocked BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+CREATE TABLE moderation_log (
+ id INTEGER GENERATED BY DEFAULT AS IDENTITY,
+ user_id INTEGER NOT NULL,
+ moderator_id INTEGER NOT NULL,
+ action moderation_action_type NOT NULL,
+ reason TEXT NOT NULL,
+ timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
+);
+
CREATE TABLE tier (
id SERIAL NOT NULL, -- PK
name CHARACTER VARYING NOT NULL,
@@ -20,12 +44,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/admin/sql/create_types.sql b/admin/sql/create_types.sql
index 877be599..a60cd510 100644
--- a/admin/sql/create_types.sql
+++ b/admin/sql/create_types.sql
@@ -1,3 +1,7 @@
+BEGIN;
+
+CREATE TYPE moderation_action_type AS ENUM ('block', 'unblock', 'comment');
+
CREATE TYPE payment_method_types AS ENUM (
'stripe',
'paypal',
@@ -29,3 +33,5 @@ CREATE TYPE dataset_project_type AS ENUM (
'listenbrainz',
'critiquebrainz'
);
+
+COMMIT;
diff --git a/admin/sql/drop_tables.sql b/admin/sql/drop_tables.sql
index edad1384..1946a2ce 100644
--- a/admin/sql/drop_tables.sql
+++ b/admin/sql/drop_tables.sql
@@ -1,10 +1,12 @@
BEGIN;
+DROP TABLE IF EXISTS "user" CASCADE;
DROP TABLE IF EXISTS payment CASCADE;
DROP TABLE IF EXISTS oauth_grant CASCADE;
DROP TABLE IF EXISTS oauth_token CASCADE;
DROP TABLE IF EXISTS oauth_client CASCADE;
DROP TABLE IF EXISTS access_log CASCADE;
+DROP TABLE IF EXISTS moderation_log CASCADE;
DROP TABLE IF EXISTS token_log CASCADE;
DROP TABLE IF EXISTS token CASCADE;
DROP TABLE IF EXISTS supporter CASCADE;
diff --git a/admin/sql/oauth/create_tables.sql b/admin/sql/oauth/create_tables.sql
index 32beab33..133f7034 100644
--- a/admin/sql/oauth/create_tables.sql
+++ b/admin/sql/oauth/create_tables.sql
@@ -1,3 +1,5 @@
+BEGIN;
+
CREATE schema oauth;
CREATE TABLE oauth.scope (
@@ -109,3 +111,5 @@ CREATE TABLE oauth.l_code_scope (
FOREIGN KEY(code_id) REFERENCES oauth.code (id) ON DELETE CASCADE,
FOREIGN KEY(scope_id) REFERENCES oauth.scope (id) ON DELETE CASCADE
);
+
+COMMIT;
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..331a8971
--- /dev/null
+++ b/frontend/css/auth-page.less
@@ -0,0 +1,107 @@
+#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;
+ }
+ .label-with-link {
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+ label {
+ flex-shrink: 0;
+ }
+ }
+ .form-label-link {
+ text-align: right;
+ }
+
+ .auth-page-container {
+ max-width: 600px;
+ 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 6rem;
+ border-radius: 3px;
+ @media screen and (max-width: @screen-xs-max) {
+ padding: 1rem 3rem;
+ }
+ }
+ .auth-card-bottom {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+ .auth-card-footer {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ 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;
+ }
+}
+
+#conditions-modal {
+ font-size: initial;
+}
+@icon-pill-size: 50px;
+@icon-logo-size: 40px;
+@icon-pill-padding: 8px;
+.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);
+ text-align: center;
+ width: @icon-pill-size;
+ height: @icon-pill-size;
+ border-radius: @icon-pill-size;
+ padding: @icon-pill-padding;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ overflow: hidden;
+ transition: width 0.42s;
+ &:hover {
+ width: 160px; // Fallback
+ width: calc(attr(data-logo-width px) + @icon-pill-padding * 2);
+ }
+ img {
+ height: @icon-logo-size;
+ max-width: unset;
+ }
+ }
+}
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/css/theme/boostrap/boostrap.less b/frontend/css/theme/boostrap/boostrap.less
index c55b2fd9..8a7204e0 100644
--- a/frontend/css/theme/boostrap/boostrap.less
+++ b/frontend/css/theme/boostrap/boostrap.less
@@ -40,7 +40,7 @@
@import "panels.less";
//@import "responsive-embed.less";
@import "wells.less";
-//@import "close.less";
+@import "close.less";
// Components w/ JavaScript
@import "modals.less";
diff --git a/frontend/img/logos/listenbrainz.svg b/frontend/img/logos/listenbrainz.svg
index 09454870..631b161b 100644
--- a/frontend/img/logos/listenbrainz.svg
+++ b/frontend/img/logos/listenbrainz.svg
@@ -1 +1,38 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
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.
+
+
+ )}
+
+
+ {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 ]"}
+
+
+
+
+ Generate new token
+
+
+
+
+ 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/ConditionsModal.tsx b/frontend/js/src/forms/ConditionsModal.tsx
new file mode 100644
index 00000000..d99573c2
--- /dev/null
+++ b/frontend/js/src/forms/ConditionsModal.tsx
@@ -0,0 +1,82 @@
+import React from "react";
+import { ProjectIconPills } from "./utils";
+
+export default function ConditionsModal() {
+ return (
+
+
+
+
+
+ ×
+
+
Privacy policy
+
+
Licensing
+
+ 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.
+
+
Privacy
+
+ MusicBrainz strongly believes 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.
+
+
GDPR compliance
+
+ 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 all of our
+ projects, such as MusicBrainz, ListenBrainz, BookBrainz, and more.
+
+ We do not automatically create accounts for these services when you
+ create a MetaBrainz account, but you will be just a few clicks away
+ from doing so.
+
+
+ Sounds good
+
+
+
+
+ );
+}
diff --git a/frontend/js/src/forms/LoginUser.tsx b/frontend/js/src/forms/LoginUser.tsx
new file mode 100644
index 00000000..528ca912
--- /dev/null
+++ b/frontend/js/src/forms/LoginUser.tsx
@@ -0,0 +1,126 @@
+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,
+ AuthCardPasswordInput,
+ 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 }) => (
+
+ )}
+
+
+
+
+
+ );
+}
+
+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..3116b3d2
--- /dev/null
+++ b/frontend/js/src/forms/LostPassword.tsx
@@ -0,0 +1,98 @@
+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 }) => (
+
+ )}
+
+
+
+
+ );
+}
+
+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..b65f4b02
--- /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 }) => (
+
+ )}
+
+
+
+
+ );
+}
+
+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 62%
rename from frontend/js/src/forms/SupporterProfileEdit.tsx
rename to frontend/js/src/forms/ProfileEdit.tsx
index b3ed8b53..7d916e50 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,35 @@ function SupporterProfileEdit({
)}
-
+ {is_supporter && (
+
+ )}
- {!is_commercial && }
+ {is_supporter && !is_commercial && (
+
+ )}
@@ -93,10 +106,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 +118,9 @@ document.addEventListener("DOMContentLoaded", () => {
const renderRoot = createRoot(domContainer!);
renderRoot.render(
-
+
+
+
Reset your password
+
{}}
+ >
+ {({ errors }) => (
+
+ )}
+
+
+
+
+ );
+}
+
+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..c41bebc8 100644
--- a/frontend/js/src/forms/SignupCommercial.tsx
+++ b/frontend/js/src/forms/SignupCommercial.tsx
@@ -4,7 +4,13 @@ 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,
+ AuthCardPasswordInput,
+ AuthCardTextInput,
+ CheckboxInput,
+ TextAreaInput,
+} from "./utils";
type AmountPledgedFieldProps = JSX.IntrinsicElements["input"] &
FieldConfig & {
@@ -18,11 +24,11 @@ function AmountPledgedField({ tier, ...props }: AmountPledgedFieldProps) {
const [field, meta] = useField(props);
return (
-
+
If you would like to support us with more than ${tier.price}, please
enter the actual amount here:
-
+
{meta.touched && meta.error ? (
-
- {meta.error}
-
+
{meta.error}
) : null}
);
@@ -48,7 +49,6 @@ type SignupCommercialProps = {
name: string;
price: number;
};
- mb_username: string;
recaptcha_site_key: string;
csrf_token: string;
initial_form_data: any;
@@ -57,367 +57,420 @@ 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.
-
+
+
+
+
+ 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.
+
-
{}}
- >
- {({ errors, setFieldValue }) => (
-
+ }
+ />
-
+
-
-
-
- Image should be about 250 pixels wide on a white or
- transparent background. We will host it on our site.
-
-
-
+
+
+ If you don't have an organization name, you probably
+ want to sign up as a{" "}
+
+ non-commercial / personal
+ {" "}
+ user.
+
+
-
-
-
- URL to where developers can use your APIs using MusicBrainz
- IDs, if available..
-
-
-
+
+
+ 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..
+
+
-
+
+
+ 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?
+
+
-
+
+
-
+
Billing address
-
+
-
-
+
-
+
-
-
-
+
-
-
- 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.
-
-
-
+
+
+ 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.
+
+
-
-
- We'll send you more details about payment process once your
- application is approved.
-
-
-
-
+
+
+ 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.
+
+
-
-
- setFieldValue("recaptcha", value)}
- />
-
-
+
+ setFieldValue("recaptcha", value)}
+ size="compact"
+ />
+
-
-
-
- Sign up
-
-
+
+
+ Sign up
+
+
+
+ )}
+
+
+
-
-
- )}
-
- >
+
+ 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 +481,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, isValid, dirty }) => (
+
+
+
+
+
+ {errors.csrf_token && (
+
+ {errors.csrf_token}
+
+ )}
+
+
+
+
+ Account type
+
+
+ Non-commercial
+
+
+
+
+
- {}}
- >
- {({ errors, setFieldValue }) => (
-
-
-
-
+ Username (public)
+ >
+ }
+ type="text"
+ name="username"
+ id="username"
+ required
/>
-
- {errors.csrf_token && (
-
{errors.csrf_token}
- )}
-
-
-
- Account type
-
-
- Non-commercial
-
-
-
+
-
-
- MusicBrainz Account
-
-
- {mb_username}
-
-
-
+
-
+
+ Must be at least 8 characters
+
+ }
+ required
+ />
-
-
+
+
-
+
+
+ Which datasets are you interested in?
+
+
+
-
-
+
+
+ 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?
+
+
+
-
-
- 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)}
+ size="compact"
+ />
+
-
-
-
- Sign up
-
-
+
+
+ Sign up
+
+
+
+
+ )}
+
+
+
+
+
);
}
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 +271,6 @@ document.addEventListener("DOMContentLoaded", () => {
renderRoot.render(
+
+
+
Create your account
+
access all MetaBrainz projects
+
{}}
+ >
+ {({ errors, setFieldValue, isValid, dirty }) => (
+
+
+
+
+
+ {errors.csrf_token && (
+
+ {errors.csrf_token}
+
+ )}
+
+
+
+ Username (public)
+ >
+ }
+ type="text"
+ name="username"
+ id="username"
+ required
+ />
+
+
+
+
+ Must be at least 8 characters
+
+ }
+ />
+
+
+
+
+ Your contributions will be released into the public domain or
+ licensed for use.{" "}
+ We will never share your personal information.
+
+
+ Click here to read more
+
+
+
+
+ setFieldValue("recaptcha", value)}
+ size="compact"
+ />
+
+
+
+ Create account
+
+
+ )}
+
+
+
+ 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..82c981f9
--- /dev/null
+++ b/frontend/js/src/forms/UserProfileEdit.tsx
@@ -0,0 +1,86 @@
+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..d0da0dbc 100644
--- a/frontend/js/src/forms/utils.tsx
+++ b/frontend/js/src/forms/utils.tsx
@@ -8,8 +8,9 @@ export type TextInputProps = JSX.IntrinsicElements["input"] &
export function TextInput({ label, children, ...props }: TextInputProps) {
const [field, meta] = useField(props);
+ const hasError = meta.touched && meta.error;
return (
-
+
{label} {props.required && * }
@@ -17,11 +18,8 @@ export function TextInput({ label, children, ...props }: TextInputProps) {
{children}
- {meta.touched && meta.error ? (
-
+ {hasError ? (
+
{meta.error}
) : null}
@@ -40,22 +38,16 @@ export function TextAreaInput({
...props
}: TextAreaInputProps) {
const [field, meta] = useField(props);
+ const hasError = meta.touched && meta.error;
return (
-
-
+
+
{label} {props.required && * }
-
-
-
{children}
- {meta.touched && meta.error ? (
-
- {meta.error}
-
+
+ {hasError ? (
+
{meta.error}
) : null}
);
@@ -72,16 +64,17 @@ export function CheckboxInput({
...props
}: CheckboxInputProps) {
const [field, meta] = useField(props);
+ const hasError = meta.touched && meta.error;
return (
-
-
+
+
{children}
- {meta.touched && meta.error ? (
-
{meta.error}
+ {hasError ? (
+
{meta.error}
) : null}
@@ -89,76 +82,164 @@ export function CheckboxInput({
);
}
-export type Dataset = {
- id: number;
- name: string;
- description: string;
-};
-
-export type DatasetsProps = {
- datasets: Dataset[];
-};
+export type OAuthTextInputProps = JSX.IntrinsicElements["input"] &
+ FieldConfig & {
+ label: string;
+ };
-export function DatasetsInput({ datasets }: DatasetsProps) {
+export function OAuthTextInput({
+ label,
+ children,
+ ...props
+}: OAuthTextInputProps) {
+ const [field, meta] = useField(props);
+ const hasError = meta.touched && meta.error;
return (
-
-
-
Datasets
+
+
+ {label} {props.required && * }
+
+
+
+ {hasError ? (
+
{meta.error}
+ ) : null}
-
-
- {datasets.map((dataset) => (
-
-
-
-
- {" "}
- {dataset.name}
-
-
-
{dataset.description}
-
- ))}
+
+ );
+}
+
+export type AuthCardTextInputProps = JSX.IntrinsicElements["input"] &
+ FieldConfig & {
+ label: string | JSX.Element;
+ labelLink?: string | JSX.Element;
+ optionalInputButton?: JSX.Element;
+ };
+
+export function AuthCardTextInput({
+ label,
+ labelLink,
+ children,
+ ...props
+}: AuthCardTextInputProps) {
+ const [field, meta] = useField(props);
+ const hasError = meta.touched && meta.error;
+ const { optionalInputButton, ...otherProps } = props;
+ const labelElement = (
+
+ {label} {props.required && * }
+
+ );
+ return (
+
+ {labelLink ? (
+
+ {labelElement}
+ {labelLink}
+ ) : (
+ labelElement
+ )}
+ {children}
+
+
+ {optionalInputButton}
+ {hasError ? (
+
{meta.error}
+ ) : null}
);
}
-export type OAuthTextInputProps = JSX.IntrinsicElements["input"] &
+export function AuthCardPasswordInput({ ...props }: AuthCardTextInputProps) {
+ const [passwordVisible, setPasswordVisible] = React.useState(false);
+ const glyphIcon = passwordVisible
+ ? "glyphicon-eye-close"
+ : "glyphicon-eye-open";
+ const title = passwordVisible ? "Hide password" : "Show password";
+ const passwordShowButton = (
+
+ {
+ setPasswordVisible((prev) => !prev);
+ }}
+ >
+
+
+
+ );
+ return (
+
+ );
+}
+
+export type AuthCardCheckboxInputProps = JSX.IntrinsicElements["input"] &
FieldConfig & {
label: string;
};
-export function OAuthTextInput({
+export function AuthCardCheckboxInput({
label,
children,
...props
-}: OAuthTextInputProps) {
+}: AuthCardCheckboxInputProps) {
const [field, meta] = useField(props);
+ const hasError = meta.touched && meta.error;
return (
-
-
- {label} {props.required && * }
+
+
+
+ Remember me
-
-
- {meta.touched && meta.error ? (
-
{meta.error}
- ) : null}
-
+ {hasError ? (
+
{meta.error}
+ ) : null}
+
+ );
+}
+export type DatasetsProps = {
+ datasets: Dataset[];
+};
+
+export function DatasetsInput({ datasets }: DatasetsProps) {
+ return (
+
+ {datasets.map((dataset) => (
+
+
+
+ {" "}
+ {dataset.name}
+
+
+
{dataset.description}
+
+ ))}
);
}
@@ -168,10 +249,63 @@ export function OAuthScopeDesc(scopes: Array) {
{/* eslint-disable-next-line react/destructuring-assignment */}
{scopes.map((scope) => (
-
+
{scope.name}: {scope.description}
))}
);
}
+
+export type AuthCardContainerProps = {
+ children?: any;
+};
+
+export function ProjectIconPills() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function AuthCardContainer({ children }: AuthCardContainerProps) {
+ return (
+
+ );
+}
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..e509b5d0 100644
--- a/metabrainz/__init__.py
+++ b/metabrainz/__init__.py
@@ -1,14 +1,16 @@
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 import AdminModelView
+from metabrainz.admin.quickbooks.views import QuickBooksView
+from metabrainz.model.user import User
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 +19,8 @@
CONSUL_CONFIG_FILE_RETRY_COUNT = 10
+bcrypt = Bcrypt()
+
def create_app(debug=None, config_path=None):
app = CustomFlask(
@@ -50,16 +54,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 +94,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 +105,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 +137,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
@@ -156,6 +155,7 @@ def create_app(debug=None, config_path=None):
from metabrainz.admin.views import PaymentsView
from metabrainz.admin.views import TokensView
from metabrainz.admin.views import StatsView
+ from metabrainz.admin.views import UserModelView
admin.add_view(CommercialSupportersView(name='Commercial supporters', category='Supporters'))
admin.add_view(SupportersView(name='Search', category='Supporters'))
admin.add_view(PaymentsView(name='All', category='Payments'))
@@ -164,7 +164,7 @@ def create_app(debug=None, config_path=None):
admin.add_view(StatsView(name='Top IPs', endpoint="statsview/top-ips", category='Statistics'))
admin.add_view(StatsView(name='Top Tokens', endpoint="statsview/top-tokens", category='Statistics'))
admin.add_view(StatsView(name='Supporters', endpoint="statsview/supporters", category='Statistics'))
-
+ admin.add_view(UserModelView(User, model.db.session, endpoint="users-admin", category="Users"))
if app.config["QUICKBOOKS_CLIENT_ID"]:
admin.add_view(QuickBooksView(name='Invoices', endpoint="quickbooks/", category='Quickbooks'))
@@ -178,10 +178,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 +191,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..a6c30363 100644
--- a/metabrainz/admin/views.py
+++ b/metabrainz/admin/views.py
@@ -1,7 +1,9 @@
from decimal import Decimal
from flask import Response, request, redirect, url_for
from flask_admin import expose
-from metabrainz.admin import AdminIndexView, AdminBaseView, forms
+from flask_login import current_user
+from metabrainz.admin import AdminIndexView, AdminBaseView, forms, AdminModelView
+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
@@ -18,6 +20,7 @@
import uuid
import json
import socket
+from metabrainz.model.user import User
from metabrainz.utils import get_int_query_param
@@ -57,9 +60,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 +85,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 +119,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 +253,7 @@ def overview(self):
token_actions=TokenLog.list(10)[0],
)
+
@staticmethod
def dns_lookup(ip):
try:
@@ -298,6 +304,7 @@ def top_ips(self):
days=days
)
+
@expose('/top-tokens/')
def top_tokens(self):
days = get_int_query_param('days', default=7)
@@ -327,6 +334,7 @@ def token_log(self):
count=count,
)
+
@expose('/usage')
def hourly_usage_data(self):
stats = AccessLog.get_hourly_usage()
@@ -350,3 +358,80 @@ def supporters(self):
supporters=supporters,
total=total
)
+
+
+class UserModelView(AdminModelView):
+ column_list = ('name', 'email', 'member_since', 'is_blocked', '')
+ column_searchable_list = ('name', 'email')
+ column_filters = ('name', 'email', 'member_since', 'is_blocked',)
+ column_default_sort = ('name', True)
+ can_create = False
+ can_delete = False
+ can_view_details = True
+
+ list_template = "admin/users/index.html"
+ details_template = "admin/users/details.html"
+
+ # todo: add csrf token to admin form
+ # add table for username deletion storage
+ # webhooks
+
+ @expose('/user//verify-email', methods=['POST'])
+ def verify_user_email(self, user_id):
+ """Verify a user's email address manually"""
+ user = User.query.get_or_404(user_id)
+
+ try:
+ user.verify_email_manually(current_user, f"Email manually verified by moderator {current_user.name}.")
+ flash.success(f'Email for {user.name} has been manually verified.')
+ except ValueError as e:
+ flash.error(str(e))
+ except Exception as e:
+ db.session.rollback()
+ flash.error('An error occurred while verifying the email.')
+ logging.exception(f'Error verifying email for user {user_id}:')
+
+ return redirect(url_for('.details_view', id=user_id))
+
+ @expose('/user//moderate', methods=['POST'])
+ def moderate_user(self, user_id):
+ """Handle user moderation actions"""
+ action = request.form.get('action')
+ if action not in ['block', 'unblock', 'comment']:
+ flash.error('Invalid moderation action.')
+ return redirect(url_for('.details_view', id=user_id))
+
+ user = User.query.get_or_404(user_id)
+ reason = request.form.get('reason', '').strip()
+
+ if not reason:
+ flash.error('A reason is required to take a moderation action on a user.')
+ return redirect(url_for('.details_view', id=user_id))
+
+ try:
+ if action == "block":
+ if user.is_blocked:
+ flash.warning('User is already blocked.')
+ else:
+ user.block(current_user, reason)
+ flash.success(f'User {user.name} has been blocked.')
+ elif action == "unblock":
+ if not user.is_blocked:
+ flash.warning('User is not currently blocked.')
+ else:
+ user.unblock(current_user, reason)
+ flash.success(f'User {user.name} has been unblocked.')
+ elif action == "comment":
+ user.moderate(current_user, "comment", reason)
+ flash.success(f'Moderation note added for user {user.name}.')
+
+ db.session.commit()
+
+ except ValueError as e:
+ flash.error(str(e))
+ except Exception as e:
+ db.session.rollback()
+ flash.error(f'An error occurred while processing the {action} action.')
+ logging.exception(f'Error in moderation action {action} for user {user_id}:')
+
+ return redirect(url_for('.details_view', id=user_id))
diff --git a/metabrainz/db/__init__.py b/metabrainz/db/__init__.py
index 4d6f003b..53e5f619 100644
--- a/metabrainz/db/__init__.py
+++ b/metabrainz/db/__init__.py
@@ -1,5 +1,5 @@
import sqlalchemy
-from sqlalchemy import create_engine
+from sqlalchemy import create_engine, text
from sqlalchemy.pool import NullPool
engine: sqlalchemy.engine.Engine = None
@@ -20,7 +20,7 @@ def init_mb_db_engine(connect_str):
def run_sql_script(sql_file_path):
with open(sql_file_path) as sql:
with engine.connect() as connection:
- connection.execute(sql.read())
+ connection.execute(text(sql.read()))
def run_sql_script_without_transaction(sql_file_path):
@@ -34,7 +34,7 @@ def run_sql_script_without_transaction(sql_file_path):
# TODO: Not a great way of removing comments. The alternative is to catch
# the exception sqlalchemy.exc.ProgrammingError "can't execute an empty query"
if line and not line.startswith("--"):
- connection.execute(line)
+ connection.execute(text(line))
except sqlalchemy.exc.ProgrammingError as e:
print("Error: {}".format(e))
finally:
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..b21d05e0
--- /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..dc406d0e
--- /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.supporter.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..bef2c804 100644
--- a/metabrainz/model/access_log_test.py
+++ b/metabrainz/model/access_log_test.py
@@ -1,8 +1,7 @@
+from metabrainz.model.user import User
from metabrainz.testing import FlaskTestCase
-from metabrainz.model import AccessLog, db, Supporter
+from metabrainz.model import AccessLog, Supporter
from metabrainz.model.supporter import STATE_ACTIVE
-from flask import current_app
-import copy
class AccessLogTestCase(FlaskTestCase):
@@ -11,27 +10,17 @@ def setUp(self):
super(AccessLogTestCase, self).setUp()
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!",
- org_desc="foo!",
- )
+ user_0 = User.add(name="mb_test", unconfirmed_email="test@musicbrainz.org", password="")
+ supporter_0 = Supporter.add(is_commercial=False, contact_name="Mr. Test", data_usage_desc="poop!",
+ org_desc="foo!", user=user_0)
supporter_0.set_state(STATE_ACTIVE)
token_0 = supporter_0.generate_token()
AccessLog.create_record(token_0, "10.1.1.69")
AccessLog.create_record(token_0, "10.1.1.69")
- 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!",
- org_desc="foo!"
- )
+ user_1 = User.add(name="mb_commercial", unconfirmed_email="testc@musicbrainz.org", password="")
+ supporter_1 = Supporter.add(is_commercial=True, contact_name="Mr. Commercial", data_usage_desc="poop!",
+ org_desc="foo!", user=user_1)
supporter_1.set_state(STATE_ACTIVE)
token_1 = supporter_1.generate_token()
AccessLog.create_record(token_1, "10.1.1.59")
diff --git a/metabrainz/model/moderation_log.py b/metabrainz/model/moderation_log.py
new file mode 100644
index 00000000..810593e3
--- /dev/null
+++ b/metabrainz/model/moderation_log.py
@@ -0,0 +1,31 @@
+
+from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, Text, func
+from sqlalchemy.orm import relationship
+from sqlalchemy.orm.attributes import Mapped
+
+from metabrainz.model import db
+
+
+class ModerationLog(db.Model):
+ __tablename__ = "moderation_log"
+
+ id = Column(Integer, primary_key=True)
+ user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
+ moderator_id = Column(Integer, ForeignKey("user.id"), nullable=False)
+ action = Column(Enum("block", "unblock", "comment", "verify_email", name="moderation_action_type"), nullable=False)
+ reason = Column(Text(), nullable=False)
+ timestamp = Column(DateTime(timezone=True), default=func.now())
+
+ user: Mapped["User"] = relationship("User", foreign_keys=[user_id], back_populates="moderation_logs")
+ moderator: Mapped["User"] = relationship("User", foreign_keys=[moderator_id], back_populates="moderator_actions")
+
+ def to_dict(self):
+ return {
+ "id": self.id,
+ "user_id": self.user_id,
+ "moderator_id": self.moderator_id,
+ "moderator_name": self.moderator.name,
+ "action": self.action,
+ "reason": self.reason,
+ "timestamp": self.timestamp.isoformat()
+ }
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..a66837cf
--- /dev/null
+++ b/metabrainz/model/user.py
@@ -0,0 +1,119 @@
+from uuid import uuid4
+from flask_login import UserMixin
+from sqlalchemy import UUID, Column, Integer, Identity, Text, DateTime, func, Boolean
+from sqlalchemy.orm import relationship
+from sqlalchemy.orm.attributes import Mapped
+
+from metabrainz.model import db
+from metabrainz.model.moderation_log import ModerationLog
+
+
+class User(db.Model, UserMixin):
+ __tablename__ = "user"
+
+ id = Column(Integer, Identity(), primary_key=True)
+ login_id = Column(UUID, nullable=False, unique=True, default=uuid4)
+ 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, nullable=False, default=False)
+ is_blocked = Column(Boolean, nullable=False, default=False)
+
+ supporter: Mapped["Supporter"] = relationship("Supporter", uselist=False, back_populates="user", lazy="joined")
+ moderation_logs: Mapped[list["ModerationLog"]] = relationship(
+ "ModerationLog", back_populates="user", foreign_keys="ModerationLog.user_id",
+ order_by="desc(ModerationLog.timestamp)"
+ )
+ moderator_actions: Mapped[list["ModerationLog"]] = relationship("ModerationLog", back_populates="moderator", foreign_keys="ModerationLog.moderator_id")
+
+ def get_id(self):
+ return str(self.login_id)
+
+ 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
+
+ def is_active(self):
+ return not self.is_blocked
+
+ @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
+
+ def moderate(self, moderator, action, reason):
+ """Moderate the user account"""
+ log = ModerationLog(
+ user_id=self.id,
+ moderator_id=moderator.id,
+ action=action,
+ reason=reason
+ )
+ db.session.add(log)
+
+ def block(self, moderator, reason):
+ """Block the user account"""
+ if self.is_blocked:
+ raise ValueError("User is already blocked")
+ self.is_blocked = True
+ self.login_id = uuid4()
+
+ self.moderate(moderator, 'block', reason)
+ db.session.commit()
+
+ def unblock(self, moderator, reason):
+ """Unblock the user account"""
+ if not self.is_blocked:
+ raise ValueError("User is not blocked")
+ self.is_blocked = False
+
+ self.moderate(moderator, 'unblock', reason)
+ db.session.commit()
+
+ def verify_email_manually(self, moderator, reason):
+ """Manually verify the user's email address"""
+ if self.is_email_confirmed():
+ raise ValueError("User's email is already verified")
+
+ if not self.unconfirmed_email:
+ raise ValueError("No email address to verify")
+
+ # Move unconfirmed email to confirmed email
+ self.email = self.unconfirmed_email
+ self.unconfirmed_email = None
+ self.email_confirmed_at = func.now()
+
+ self.moderate(moderator, 'verify_email', reason)
+ db.session.commit()
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..3e528170 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,19 +25,15 @@ 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}
+ self.descriptions = {d["id"]: d["description"] for d in available_datasets}
class CommercialSignUpForm(SupporterSignUpForm):
@@ -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..f2f75aab 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,83 @@ 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_dataset_objs = Dataset.query.all()
+ available_datasets = [
+ {"id": d.id, "description": d.description, "name": d.name}
+ for d in available_dataset_objs
+ ]
+ 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)
+ selected_datasets = []
+ for dataset_dict in form.datasets.data:
+ for dataset_obj in available_dataset_objs:
+ if dataset_obj.id == dataset_dict["id"]:
+ selected_datasets.append(dataset_obj)
+
+ Supporter.add(
+ is_commercial=False,
+ contact_name=form.contact_name.data,
+ data_usage_desc=form.usage_desc.data,
+ datasets=selected_datasets,
+ 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..b1a1d0f8 100644
--- a/metabrainz/supporter/views_test.py
+++ b/metabrainz/supporter/views_test.py
@@ -32,9 +32,6 @@ def test_tier(self):
self.assert200(self.client.get(url_for('supporters.tier', tier_id=t.id)))
self.assert404(self.client.get(url_for('supporters.tier', tier_id=t.id + 1)))
- def test_signup(self):
- self.assert200(self.client.get(url_for('supporters.signup')))
-
def test_signup_commercial(self):
resp = self.client.get(url_for('supporters.signup_commercial'))
self.assertEqual(resp.location, urlparse(url_for('supporters.account_type')).path)
@@ -54,33 +51,15 @@ def test_signup_commercial(self):
resp = self.client.get(url_for('supporters.signup_commercial', tier_id=unavailable_tier.id + 1))
self.assertEqual(resp.location, url_for('supporters.account_type'))
- def test_musicbrainz(self):
- self.assertStatus(self.client.get(url_for('supporters.musicbrainz')), 302)
-
- def test_musicbrainz_post(self):
- app = create_app(debug=True, config_path='../config.py')
- app.config['TESTING'] = True
- client = app.test_client()
-
- self.assert500(client.get(url_for('supporters.musicbrainz_post')))
- self.assert400(client.get(url_for('supporters.musicbrainz_post', error="PANIC")))
- 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!
- Name
+ Organization Name
Contact name
Contact email
- MusicBrainz ID
+ Username
Tier
Applied on
@@ -18,8 +18,8 @@ Welcome!
{{ supporter.org_name }}
{{ supporter.contact_name }}
- {{ supporter.contact_email }}
- {{ supporter.musicbrainz_id }}
+ {{ supporter.user.email }}
+ {{ supporter.user.name }}
{{ supporter.tier }}
{{ supporter.created }}
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() }}