├── critiquebrainz ├── data │ ├── __init__.py │ ├── test │ │ ├── __init__.py │ │ └── utils_test.py │ ├── testing.py │ ├── mixins.py │ ├── user_types.py │ └── fixtures.py ├── test │ ├── __init__.py │ └── utils_test.py ├── db │ ├── test │ │ ├── __init__.py │ │ └── license_test.py │ ├── exceptions.py │ ├── comment_revision.py │ └── __init__.py ├── ws │ ├── review │ │ ├── __init__.py │ │ └── test │ │ │ └── __init__.py │ ├── user │ │ ├── __init__.py │ │ └── test │ │ │ ├── __init__.py │ │ │ └── views_test.py │ ├── constants.py │ ├── oauth │ │ └── __init__.py │ ├── errors.py │ └── testing.py ├── frontend │ ├── forms │ │ ├── __init__.py │ │ ├── utils.py │ │ ├── log.py │ │ ├── comment.py │ │ ├── profile.py │ │ └── rate.py │ ├── views │ │ ├── test │ │ │ ├── __init__.py │ │ │ ├── test_moderators.py │ │ │ ├── test_release.py │ │ │ ├── test_work.py │ │ │ ├── test_event.py │ │ │ ├── test_markdown.py │ │ │ ├── test_search.py │ │ │ ├── test_index.py │ │ │ ├── test_place.py │ │ │ ├── test_bb_series.py │ │ │ ├── test_bb_author.py │ │ │ ├── test_recording.py │ │ │ ├── test_release_group.py │ │ │ ├── test_bb_literary_work.py │ │ │ └── test_bb_edition_group.py │ │ ├── release.py │ │ ├── __init__.py │ │ ├── markdown.py │ │ ├── moderators.py │ │ ├── statistics.py │ │ ├── log.py │ │ ├── index.py │ │ ├── profile.py │ │ └── login.py │ ├── static │ │ ├── .gitignore │ │ ├── scripts │ │ │ ├── main.js │ │ │ ├── common.js │ │ │ ├── leaflet.js │ │ │ ├── global.js │ │ │ ├── wysiwyg-editor.js │ │ │ └── rating.js │ │ ├── images │ │ │ ├── layers.png │ │ │ ├── favicon.png │ │ │ ├── layers-2x.png │ │ │ ├── marker-icon.png │ │ │ ├── marker-icon-2x.png │ │ │ ├── marker-shadow.png │ │ │ ├── placeholder_author.svg │ │ │ ├── placeholder_disc.svg │ │ │ ├── placeholder_edition_group.svg │ │ │ ├── placeholder_literary_work.svg │ │ │ ├── placeholder_place.svg │ │ │ └── placeholder_series.svg │ │ ├── favicons │ │ │ ├── home-16.png │ │ │ ├── imdb-16.png │ │ │ ├── viaf-16.png │ │ │ ├── lastfm-16.png │ │ │ ├── allmusic-16.png │ │ │ ├── bandcamp-16.png │ │ │ ├── discogs-16.png │ │ │ ├── external-16.png │ │ │ ├── twitter-16.png │ │ │ ├── wikidata-16.png │ │ │ ├── wikipedia-16.png │ │ │ ├── youtube-16.png │ │ │ ├── librarything-16.png │ │ │ ├── bookbrainz-16.svg │ │ │ └── musicbrainz-16.svg │ │ ├── fonts │ │ │ ├── glyphicons-halflings-regular.eot │ │ │ ├── glyphicons-halflings-regular.ttf │ │ │ ├── glyphicons-halflings-regular.woff │ │ │ └── glyphicons-halflings-regular.woff2 │ │ ├── styles │ │ │ └── theme │ │ │ │ ├── boostrap │ │ │ │ ├── mixins │ │ │ │ │ ├── center-block.less │ │ │ │ │ ├── text-emphasis.less │ │ │ │ │ ├── size.less │ │ │ │ │ ├── opacity.less │ │ │ │ │ ├── background-variant.less │ │ │ │ │ ├── text-overflow.less │ │ │ │ │ ├── tab-focus.less │ │ │ │ │ ├── labels.less │ │ │ │ │ ├── resize.less │ │ │ │ │ ├── progress-bar.less │ │ │ │ │ ├── reset-filter.less │ │ │ │ │ ├── nav-divider.less │ │ │ │ │ ├── alerts.less │ │ │ │ │ ├── nav-vertical-align.less │ │ │ │ │ ├── responsive-visibility.less │ │ │ │ │ ├── pagination.less │ │ │ │ │ ├── border-radius.less │ │ │ │ │ ├── panels.less │ │ │ │ │ ├── list-group.less │ │ │ │ │ ├── hide-text.less │ │ │ │ │ ├── clearfix.less │ │ │ │ │ ├── table-row.less │ │ │ │ │ ├── image.less │ │ │ │ │ └── buttons.less │ │ │ │ ├── wells.less │ │ │ │ ├── breadcrumbs.less │ │ │ │ ├── responsive-embed.less │ │ │ │ ├── component-animations.less │ │ │ │ ├── close.less │ │ │ │ ├── thumbnails.less │ │ │ │ ├── utilities.less │ │ │ │ ├── media.less │ │ │ │ ├── pager.less │ │ │ │ ├── jumbotron.less │ │ │ │ ├── mixins.less │ │ │ │ ├── labels.less │ │ │ │ ├── boostrap.less │ │ │ │ ├── badges.less │ │ │ │ └── code.less │ │ │ │ ├── theme.less │ │ │ │ ├── buttons.less │ │ │ │ └── links.less │ │ └── robots.txt │ ├── external │ │ ├── relationships │ │ │ └── __init__.py │ │ ├── bookbrainz_db │ │ │ ├── test │ │ │ │ ├── __init__.py │ │ │ │ ├── bb_relationship_test.py │ │ │ │ ├── bb_redirects_test.py │ │ │ │ ├── common_entity_test.py │ │ │ │ ├── author_test.py │ │ │ │ └── series_test.py │ │ │ ├── __init__.py │ │ │ └── exceptions.py │ │ ├── musicbrainz_db │ │ │ ├── test │ │ │ │ └── __init__.py │ │ │ ├── __init__.py │ │ │ ├── work.py │ │ │ ├── release.py │ │ │ ├── recording.py │ │ │ ├── label.py │ │ │ ├── artist.py │ │ │ └── place.py │ │ ├── exceptions.py │ │ ├── notify_moderators.py │ │ └── bookbrainz.py │ ├── .gitignore │ ├── babel.cfg │ ├── templates │ │ ├── errors │ │ │ ├── 401.html │ │ │ ├── 403.html │ │ │ ├── 404.html │ │ │ ├── 400.html │ │ │ ├── 503.html │ │ │ ├── 500.html │ │ │ └── base.html │ │ ├── entity_review.html │ │ ├── emails │ │ │ └── review_report.txt │ │ ├── review │ │ │ ├── modify │ │ │ │ ├── bb_author.html │ │ │ │ ├── bb_literary_work.html │ │ │ │ ├── bb_series.html │ │ │ │ ├── bb_edition_group.html │ │ │ │ ├── place.html │ │ │ │ ├── artist.html │ │ │ │ ├── label.html │ │ │ │ ├── event.html │ │ │ │ ├── recording.html │ │ │ │ ├── release_group.html │ │ │ │ ├── work.html │ │ │ │ ├── edit.html │ │ │ │ └── write.html │ │ │ ├── revision_results.html │ │ │ ├── entity │ │ │ │ ├── bb_author.html │ │ │ │ ├── bb_series.html │ │ │ │ ├── bb_edition_group.html │ │ │ │ ├── work.html │ │ │ │ ├── bb_literary_work.html │ │ │ │ ├── label.html │ │ │ │ ├── artist.html │ │ │ │ └── recording.html │ │ │ ├── delete.html │ │ │ ├── delete_comment.html │ │ │ ├── compare.html │ │ │ └── report.html │ │ ├── common.html │ │ ├── login │ │ │ └── index.html │ │ ├── profile │ │ │ └── delete.html │ │ ├── moderators │ │ │ └── moderators.html │ │ ├── sharing.html │ │ ├── log │ │ │ ├── browse.html │ │ │ └── log_results.html │ │ ├── base.html │ │ └── user │ │ │ └── base.html │ ├── testing.py │ ├── static_manager.py │ ├── flash.py │ ├── error_handlers.py │ ├── login │ │ └── __init__.py │ └── babel.py ├── __init__.py └── expand.py ├── docs ├── .gitignore ├── requirements.txt ├── api.rst ├── db.rst ├── contributing.rst ├── index.rst ├── export.rst └── api │ └── endpoints.rst ├── .babelrc ├── .github ├── release-drafter.yml └── workflows │ ├── release-drafter.yml │ ├── deploy-image.yml │ └── tests.yml ├── admin ├── sql │ ├── create_extensions.sql │ ├── drop_types.sql │ ├── create_indexes.sql │ ├── clear_tables.sql │ ├── create_types.sql │ ├── drop_tables.sql │ ├── create_primary_keys.sql │ └── test │ │ └── bb-test-data.sql ├── schema_changes │ ├── 7.sql │ ├── 16.sql │ ├── 17.sql │ ├── 18.sql │ ├── 19.sql │ ├── 21.sql │ ├── 3.sql │ ├── 20.sql │ ├── 23.sql │ ├── 2.sql │ ├── 4.sql │ ├── 13.sql │ ├── 22.sql │ ├── 12.sql │ ├── 10.sql │ ├── 15.sql │ ├── 6.sql │ ├── 9.sql │ ├── 14.sql │ ├── 8.sql │ ├── 5.sql │ └── 11.sql ├── config.sh.ctmpl ├── functions.sh └── rsync-dump-files.sh ├── .gitattributes ├── docker ├── uwsgi │ ├── uwsgi.service │ ├── consul-template-uwsgi.conf │ └── uwsgi.ini ├── cron │ ├── cron-config.service │ ├── consul-template-cron-config.conf │ └── crontab ├── pg_custom │ ├── create-bb-db.sh │ └── create-cb-db.sh ├── push.sh ├── rc.local ├── docker-compose.test.yml └── docker-compose.dev.yml ├── Dockerfile.webpack ├── pytest.ini ├── .flake8 ├── .readthedocs.yaml ├── scripts ├── add-test-bookbrainz-data.sh └── download-import-bookbrainz-dump.sh ├── .tx └── config ├── test_config.py ├── .gitignore ├── .dockerignore ├── CONTRIBUTING.md ├── requirements.txt ├── custom_config.py.example └── package.json /critiquebrainz/data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/data/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/db/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/ws/review/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/ws/user/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/forms/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/ws/review/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/ws/user/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1' 2 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/relationships/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Sphinx stuff 2 | _build 3 | build 4 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Translations 2 | *.mo 3 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/bookbrainz_db/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/musicbrainz_db/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What’s Changed 3 | 4 | $CHANGES -------------------------------------------------------------------------------- /admin/sql/create_extensions.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 2 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/scripts/main.js: -------------------------------------------------------------------------------- 1 | import '../styles/main.less'; 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /admin/schema_changes/7.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE entity_types ADD VALUE 'place' AFTER 'event'; 2 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/babel.cfg: -------------------------------------------------------------------------------- 1 | [python: **.py] 2 | [jinja2: **/templates/**.html] 3 | -------------------------------------------------------------------------------- /admin/schema_changes/16.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE entity_types ADD VALUE 'artist' AFTER 'place'; 2 | -------------------------------------------------------------------------------- /admin/schema_changes/17.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE entity_types ADD VALUE 'label' AFTER 'artist'; 2 | -------------------------------------------------------------------------------- /admin/schema_changes/18.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE entity_types ADD VALUE 'recording' AFTER 'label'; 2 | -------------------------------------------------------------------------------- /admin/schema_changes/19.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE entity_types ADD VALUE 'work' AFTER 'recording'; 2 | -------------------------------------------------------------------------------- /admin/schema_changes/21.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE entity_types ADD VALUE 'bb_edition_group' AFTER 'recording'; 2 | -------------------------------------------------------------------------------- /admin/schema_changes/3.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | ALTER TABLE review DROP COLUMN is_archived; 4 | 5 | COMMIT; 6 | -------------------------------------------------------------------------------- /admin/schema_changes/20.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | ALTER TABLE "user" DROP COLUMN "show_gravatar"; 4 | 5 | COMMIT; 6 | -------------------------------------------------------------------------------- /critiquebrainz/ws/constants.py: -------------------------------------------------------------------------------- 1 | available_scopes = ( 2 | 'user', 3 | 'review', 4 | 'vote', 5 | ) 6 | -------------------------------------------------------------------------------- /admin/schema_changes/23.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | CREATE INDEX ix_user_id ON "user" USING btree ((id::text)); 3 | COMMIT; 4 | -------------------------------------------------------------------------------- /docker/uwsgi/uwsgi.service: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exec run-consul-template -config /etc/consul-template-uwsgi.conf 4 | -------------------------------------------------------------------------------- /admin/sql/drop_types.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | DROP TYPE IF EXISTS action_types; 3 | DROP TYPE IF EXISTS entity_types; 4 | COMMIT; 5 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/musicbrainz_db/__init__.py: -------------------------------------------------------------------------------- 1 | DEFAULT_CACHE_EXPIRATION = 12 * 60 * 60 # seconds (12 hours) -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx==5.0.2 2 | Pygments==2.15.0 3 | sphinxcontrib-httpdomain==1.8.0 4 | -r ../requirements.txt 5 | -------------------------------------------------------------------------------- /admin/schema_changes/2.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | ALTER TABLE "user" ALTER COLUMN show_gravatar SET DEFAULT False; 4 | 5 | COMMIT; 6 | -------------------------------------------------------------------------------- /admin/schema_changes/4.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | ALTER TABLE spam_report ADD COLUMN reason CHARACTER VARYING; 4 | 5 | COMMIT; 6 | -------------------------------------------------------------------------------- /docker/cron/cron-config.service: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | exec run-consul-template -config /etc/consul-template-cron-config.conf 4 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/errors/401.html: -------------------------------------------------------------------------------- 1 | {% extends 'errors/base.html' %} 2 | {% block error_title %}{{ _('Unauthorized') }}{% endblock %} 3 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/errors/403.html: -------------------------------------------------------------------------------- 1 | {% extends 'errors/base.html' %} 2 | {% block error_title %}{{ _('Access denied') }}{% endblock %} 3 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/errors/404.html: -------------------------------------------------------------------------------- 1 | {% extends 'errors/base.html' %} 2 | {% block error_title %}{{ _('Page not found') }}{% endblock %} 3 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/images/layers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/images/layers.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/errors/400.html: -------------------------------------------------------------------------------- 1 | {% extends 'errors/base.html' %} 2 | {% block error_title %}{{ _('Invalid request') }}{% endblock %} 3 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/errors/503.html: -------------------------------------------------------------------------------- 1 | {% extends 'errors/base.html' %} 2 | {% block error_title %}{{ _('Service unavailable') }}{% endblock %} 3 | -------------------------------------------------------------------------------- /critiquebrainz/data/testing.py: -------------------------------------------------------------------------------- 1 | from critiquebrainz.frontend.testing import FrontendTestCase 2 | 3 | 4 | class DataTestCase(FrontendTestCase): 5 | pass 6 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/home-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/favicons/home-16.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/imdb-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/favicons/imdb-16.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/viaf-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/favicons/viaf-16.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/images/favicon.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/images/layers-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/images/layers-2x.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/scripts/common.js: -------------------------------------------------------------------------------- 1 | var global = require("./global"); 2 | global.$ = global.jQuery = require("jquery"); 3 | require("bootstrap"); 4 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/errors/500.html: -------------------------------------------------------------------------------- 1 | {% extends 'errors/base.html' %} 2 | {% block error_title %}{{ _('Internal server error') }}{% endblock %} 3 | -------------------------------------------------------------------------------- /Dockerfile.webpack: -------------------------------------------------------------------------------- 1 | FROM node:20 2 | 3 | RUN mkdir /code 4 | WORKDIR /code 5 | 6 | COPY package.json package-lock.json webpack.config.js /code/ 7 | RUN npm install 8 | -------------------------------------------------------------------------------- /admin/schema_changes/13.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | ALTER TABLE "comment_revision" 4 | ALTER COLUMN text 5 | SET NOT NULL, 6 | ADD CHECK (text <> ''); 7 | 8 | COMMIT; -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/lastfm-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/favicons/lastfm-16.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/images/marker-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/images/marker-icon.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/scripts/leaflet.js: -------------------------------------------------------------------------------- 1 | import '../styles/leaflet.less'; 2 | var L = require('leaflet'); 3 | L.Icon.Default.imagePath = '/static/images/'; 4 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/allmusic-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/favicons/allmusic-16.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/bandcamp-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/favicons/bandcamp-16.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/discogs-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/favicons/discogs-16.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/external-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/favicons/external-16.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/twitter-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/favicons/twitter-16.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/wikidata-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/favicons/wikidata-16.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/wikipedia-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/favicons/wikipedia-16.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/youtube-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/favicons/youtube-16.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/images/marker-icon-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/images/marker-icon-2x.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/images/marker-shadow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/images/marker-shadow.png -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/scripts/global.js: -------------------------------------------------------------------------------- 1 | if (typeof global === 'undefined') { 2 | module.exports = window; 3 | } else { 4 | module.exports = global; 5 | } 6 | -------------------------------------------------------------------------------- /critiquebrainz/ws/oauth/__init__.py: -------------------------------------------------------------------------------- 1 | from critiquebrainz.ws.oauth.provider import CritiqueBrainzAuthorizationProvider 2 | 3 | oauth = CritiqueBrainzAuthorizationProvider() 4 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/librarything-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/favicons/librarything-16.png -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = critiquebrainz 3 | addopts = --cov=critiquebrainz --no-cov-on-fail -W always::DeprecationWarning -W error::sqlalchemy.exc.Base20DeprecationWarning 4 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/scripts/wysiwyg-editor.js: -------------------------------------------------------------------------------- 1 | import '../../../../node_modules/easymde/dist/easymde.min.css'; 2 | var EasyMDE = require('easymde'); 3 | window.EasyMDE = EasyMDE; 4 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max_line_length = 130 3 | exclude = 4 | .git, 5 | __pycache__, 6 | old, 7 | build, 8 | dist, 9 | ./custom_config.py, 10 | ./docs/conf.py 11 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabrainz/critiquebrainz/HEAD/critiquebrainz/frontend/static/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/center-block.less: -------------------------------------------------------------------------------- 1 | // Center-align a block level element 2 | 3 | .center-block() { 4 | display: block; 5 | margin-left: auto; 6 | margin-right: auto; 7 | } 8 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/text-emphasis.less: -------------------------------------------------------------------------------- 1 | // Typography 2 | 3 | .text-emphasis-variant(@color) { 4 | color: @color; 5 | a&:hover { 6 | color: darken(@color, 10%); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /login 3 | Disallow: /profile 4 | Disallow: /ws 5 | Disallow: /log 6 | Disallow: /reports 7 | Disallow: /review 8 | Disallow: /mapping 9 | Disallow: /oauth 10 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/size.less: -------------------------------------------------------------------------------- 1 | // Sizing shortcuts 2 | 3 | .size(@width; @height) { 4 | width: @width; 5 | height: @height; 6 | } 7 | 8 | .square(@size) { 9 | .size(@size; @size); 10 | } 11 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/opacity.less: -------------------------------------------------------------------------------- 1 | // Opacity 2 | 3 | .opacity(@opacity) { 4 | opacity: @opacity; 5 | // IE8 filter 6 | @opacity-ie: (@opacity * 100); 7 | filter: ~"alpha(opacity=@{opacity-ie})"; 8 | } 9 | -------------------------------------------------------------------------------- /admin/schema_changes/22.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE entity_types ADD VALUE 'bb_literary_work' AFTER 'bb_edition_group'; 2 | ALTER TYPE entity_types ADD VALUE 'bb_author' AFTER 'bb_literary_work'; 3 | ALTER TYPE entity_types ADD VALUE 'bb_series' AFTER 'bb_author'; 4 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/background-variant.less: -------------------------------------------------------------------------------- 1 | // Contextual backgrounds 2 | 3 | .bg-variant(@color) { 4 | background-color: @color; 5 | a&:hover { 6 | background-color: darken(@color, 10%); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/text-overflow.less: -------------------------------------------------------------------------------- 1 | // Text overflow 2 | // Requires inline-block or block for proper styling 3 | 4 | .text-overflow() { 5 | overflow: hidden; 6 | text-overflow: ellipsis; 7 | white-space: nowrap; 8 | } 9 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-20.04 5 | tools: 6 | python: "3.10" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | formats: all 12 | 13 | python: 14 | install: 15 | - requirements: docs/requirements.txt 16 | -------------------------------------------------------------------------------- /admin/schema_changes/12.sql: -------------------------------------------------------------------------------- 1 | -- Add a MusicBrainz Row ID column to the user table 2 | BEGIN; 3 | 4 | ALTER TABLE "user" ADD COLUMN musicbrainz_row_id INTEGER; 5 | ALTER TABLE "user" ADD CONSTRAINT user_musicbrainz_row_id_key UNIQUE (musicbrainz_row_id); 6 | 7 | COMMIT; 8 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/tab-focus.less: -------------------------------------------------------------------------------- 1 | // WebKit-style focus 2 | 3 | .tab-focus() { 4 | // Default 5 | outline: thin dotted; 6 | // WebKit 7 | outline: 5px auto -webkit-focus-ring-color; 8 | outline-offset: -2px; 9 | } 10 | -------------------------------------------------------------------------------- /scripts/add-test-bookbrainz-data.sh: -------------------------------------------------------------------------------- 1 | DB_HOSTNAME=db 2 | DB_PORT=5432 3 | DB_USER=bookbrainz 4 | DB_NAME=bookbrainz 5 | DB_PASSWORD=bookbrainz 6 | export PGPASSWORD=$DB_PASSWORD 7 | 8 | psql -f admin/sql/test/bb-test-data.sql -h $DB_HOSTNAME -p $DB_PORT -U $DB_USER -d $DB_NAME 9 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | 4 | [critiquebrainz.critiquebrainz] 5 | file_filter = critiquebrainz/frontend/translations//LC_MESSAGES/messages.po 6 | source_file = critiquebrainz/frontend/messages.pot 7 | source_lang = en 8 | type = PO 9 | 10 | -------------------------------------------------------------------------------- /admin/schema_changes/10.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | ALTER TABLE "user" ADD COLUMN license_choice VARCHAR; 4 | 5 | ALTER TABLE "user" 6 | ADD CONSTRAINT user_license_choice_fkey 7 | FOREIGN KEY (license_choice) 8 | REFERENCES license(id) 9 | ON DELETE CASCADE; 10 | 11 | COMMIT; 12 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/exceptions.py: -------------------------------------------------------------------------------- 1 | class ExternalServiceException(Exception): 2 | """Base exception for this package. 3 | 4 | Should be used when an error occurs with one fo the external services that 5 | the CritiqueBrainz frontend interacts with. 6 | """ 7 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/scripts/rating.js: -------------------------------------------------------------------------------- 1 | require('bootstrap-rating-input'); 2 | 3 | $.fn.rating.Constructor.DEFAULTS['empty-value'] = null; 4 | $.fn.rating.Constructor.DEFAULTS['clearable'] = ''; 5 | $.fn.rating.Constructor.DEFAULTS['clearableIcon'] = 'glyphicon-remove-circle'; 6 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/labels.less: -------------------------------------------------------------------------------- 1 | // Labels 2 | 3 | .label-variant(@color) { 4 | background-color: @color; 5 | 6 | &[href] { 7 | &:hover, 8 | &:focus { 9 | background-color: darken(@color, 10%); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/resize.less: -------------------------------------------------------------------------------- 1 | // Resize anything 2 | 3 | .resizable(@direction) { 4 | resize: @direction; // Options: horizontal, vertical, both 5 | overflow: auto; // Per CSS3 UI, `resize` only applies when `overflow` isn't `visible` 6 | } 7 | -------------------------------------------------------------------------------- /docker/cron/consul-template-cron-config.conf: -------------------------------------------------------------------------------- 1 | template { 2 | source = "/code/consul_config.py.ctmpl" 3 | destination = "/code/consul_config.py" 4 | } 5 | 6 | template { 7 | source = "/code/admin/config.sh.ctmpl" 8 | destination = "/code/admin/config.sh" 9 | } 10 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/progress-bar.less: -------------------------------------------------------------------------------- 1 | // Progress bars 2 | 3 | .progress-bar-variant(@color) { 4 | background-color: @color; 5 | 6 | // Deprecated parent class requirement as of v3.2.0 7 | .progress-striped & { 8 | #gradient > .striped(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /admin/schema_changes/15.sql: -------------------------------------------------------------------------------- 1 | -- NOTE: this cannot be inside a transaction because 2 | -- postgres currently does not support adding values 3 | -- to enums inside a transaction block 4 | ALTER TYPE action_types ADD VALUE 'unhide_review' AFTER 'hide_review'; 5 | ALTER TYPE action_types ADD VALUE 'unblock_user' AFTER 'block_user'; 6 | -------------------------------------------------------------------------------- /docker/pg_custom/create-bb-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL 5 | CREATE USER bookbrainz WITH SUPERUSER PASSWORD 'bookbrainz'; 6 | CREATE DATABASE bookbrainz; 7 | GRANT ALL PRIVILEGES ON DATABASE bookbrainz TO bookbrainz; 8 | EOSQL -------------------------------------------------------------------------------- /admin/sql/create_indexes.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE INDEX ix_oauth_grant_code ON oauth_grant USING btree (code); 4 | CREATE INDEX ix_review_entity_id ON review USING btree (entity_id); 5 | CREATE INDEX ix_revision_review_id ON revision USING btree (review_id); 6 | CREATE INDEX ix_user_id ON "user" USING btree ((id::text)); 7 | 8 | COMMIT; 9 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | CritqueBrainz provides an API which you can use to interact with content on the 5 | website. There's also an :doc:`OAuth ` protocol implementation 6 | which you can build your applications on top of. 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | 11 | api/endpoints 12 | api/oauth2 13 | -------------------------------------------------------------------------------- /docker/pg_custom/create-cb-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL 5 | CREATE USER critiquebrainz WITH SUPERUSER PASSWORD 'critiquebrainz'; 6 | CREATE DATABASE critiquebrainz; 7 | GRANT ALL PRIVILEGES ON DATABASE critiquebrainz TO critiquebrainz; 8 | EOSQL -------------------------------------------------------------------------------- /docker/uwsgi/consul-template-uwsgi.conf: -------------------------------------------------------------------------------- 1 | template { 2 | source = "/code/consul_config.py.ctmpl" 3 | destination = "/code/consul_config.py" 4 | } 5 | exec { 6 | command = ["uwsgi", "/etc/uwsgi/uwsgi.ini"] 7 | splay = "60s" 8 | reload_signal = "SIGHUP" 9 | kill_signal = "SIGTERM" 10 | kill_timeout = "30s" 11 | } 12 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/forms/utils.py: -------------------------------------------------------------------------------- 1 | import pycountry 2 | from babel.core import UnknownLocaleError, Locale 3 | 4 | 5 | def get_language_name(language_code): 6 | try: 7 | return Locale(language_code).language_name 8 | except UnknownLocaleError: 9 | return pycountry.languages.get(iso639_1_code=language_code).name 10 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/reset-filter.less: -------------------------------------------------------------------------------- 1 | // Reset filters for IE 2 | // 3 | // When you need to remove a gradient background, do not forget to use this to reset 4 | // the IE filter for IE9 and below. 5 | 6 | .reset-filter() { 7 | filter: e(%("progid:DXImageTransform.Microsoft.gradient(enabled = false)")); 8 | } 9 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/nav-divider.less: -------------------------------------------------------------------------------- 1 | // Horizontal dividers 2 | // 3 | // Dividers (basically an hr) within dropdowns and nav lists 4 | 5 | .nav-divider(@color: #e5e5e5) { 6 | height: 1px; 7 | margin: ((@line-height-computed / 2) - 1) 0; 8 | overflow: hidden; 9 | background-color: @color; 10 | } 11 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/entity_review.html: -------------------------------------------------------------------------------- 1 | 2 | {% if review.entity_type == 'release_group' %} 3 | {{ entity.title }} {{ _('by') }} {{ entity['artist-credit-phrase'] | default(_('[Unknown artist]')) }} 4 | {% else %} 5 | {{ entity.name }} 6 | {% endif %} 7 | 8 | -------------------------------------------------------------------------------- /test_config.py: -------------------------------------------------------------------------------- 1 | # Testing configuration 2 | 3 | DEBUG = False 4 | SECRET_KEY = "test" 5 | WTF_CSRF_ENABLED = False 6 | TESTING = True 7 | 8 | # MusicBrainz Database 9 | MB_DATABASE_URI = "postgresql://musicbrainz@musicbrainz_db:5432/musicbrainz_db" 10 | 11 | # BookBrainz Database 12 | BB_DATABASE_URI = "postgresql://bookbrainz:bookbrainz@db:5432/bookbrainz" -------------------------------------------------------------------------------- /critiquebrainz/db/exceptions.py: -------------------------------------------------------------------------------- 1 | class DatabaseException(Exception): 2 | """Base exception for this package.""" 3 | 4 | 5 | class NoDataFoundException(DatabaseException): 6 | """Should be used when no data has been found.""" 7 | 8 | 9 | class BadDataException(DatabaseException): 10 | """Should be used when incorrect data is being submitted.""" 11 | -------------------------------------------------------------------------------- /admin/schema_changes/6.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | ALTER TABLE review RENAME COLUMN release_group TO entity_id; 4 | 5 | CREATE TYPE entity_types AS enum ( 6 | 'event', 7 | 'release_group' 8 | ); 9 | 10 | ALTER TABLE review ADD entity_type entity_types NOT NULL DEFAULT 'release_group'; 11 | ALTER TABLE review ALTER COLUMN entity_type DROP DEFAULT; 12 | 13 | COMMIT; 14 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/forms/log.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from flask_babel import lazy_gettext 3 | from wtforms import TextAreaField, validators 4 | 5 | 6 | class AdminActionForm(FlaskForm): 7 | reason = TextAreaField(validators=[ 8 | validators.InputRequired(message=lazy_gettext("You need to specify a reason for taking this action.")) 9 | ]) 10 | -------------------------------------------------------------------------------- /admin/sql/clear_tables.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM comment_revision; 2 | DELETE FROM comment; 3 | DELETE FROM vote; 4 | DELETE FROM spam_report; 5 | DELETE FROM revision; 6 | DELETE FROM oauth_grant; 7 | DELETE FROM oauth_token; 8 | DELETE FROM oauth_client; 9 | DELETE FROM moderation_log; 10 | DELETE FROM review; 11 | DELETE FROM "user"; 12 | DELETE FROM license; 13 | DELETE FROM avg_rating; 14 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/alerts.less: -------------------------------------------------------------------------------- 1 | // Alerts 2 | 3 | .alert-variant(@background; @border; @text-color) { 4 | background-color: @background; 5 | border-color: @border; 6 | color: @text-color; 7 | 8 | hr { 9 | border-top-color: darken(@border, 5%); 10 | } 11 | .alert-link { 12 | color: darken(@text-color, 10%); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/db.rst: -------------------------------------------------------------------------------- 1 | Database 2 | ======== 3 | 4 | CritiqueBrainz uses `PostgresSQL `_ DBMS to save reviews and other data. 5 | 6 | Entities 7 | -------- 8 | 9 | * Review 10 | * Revision 11 | * License 12 | * User 13 | * Vote 14 | * SpamReport 15 | * OAuthClient 16 | * OAuthGrant 17 | * OAuthToken 18 | 19 | Schema diagram 20 | -------------- 21 | 22 | .. image:: /images/schema.svg 23 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/emails/review_report.txt: -------------------------------------------------------------------------------- 1 | Hi CritiqueBrainz moderator! 2 | 3 | {{username}} has just reported a spam review. 4 | 5 | Details: 6 | * Review link: https://critiquebrainz.org{{review_link}} 7 | * Reviewed By: {{review_author}} 8 | * Reason for reporting spam: {{reason}} 9 | 10 | Please take a look at CritiqueBrainz reports page for more information. 11 | 12 | Best, 13 | The CritiqueBrainz Team -------------------------------------------------------------------------------- /admin/schema_changes/9.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | ALTER TABLE review ADD COLUMN published_on TIMESTAMP; 4 | 5 | UPDATE review 6 | SET published_on = sub.latest 7 | FROM ( SELECT review_id, MAX(rv.timestamp) as latest 8 | FROM review r 9 | JOIN revision rv 10 | ON r.id = rv.review_id 11 | GROUP BY review_id 12 | ) AS sub 13 | WHERE is_draft = 'f' AND sub.review_id = id; 14 | 15 | COMMIT; 16 | -------------------------------------------------------------------------------- /docker/uwsgi/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | uid = www-data 3 | gid = www-data 4 | master = true 5 | socket = 0.0.0.0:13032 6 | module = manage 7 | callable = application 8 | chdir = /code/ 9 | enable-threads = true 10 | processes = 20 11 | disable-logging = true 12 | ; increase buffer size for requests that send a lot of mbids in query params 13 | buffer-size = 8192 14 | need-app = true 15 | log-x-forwarded-for = true 16 | die-on-term = true 17 | -------------------------------------------------------------------------------- /admin/sql/create_types.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE action_types AS ENUM ( 2 | 'hide_review', 3 | 'unhide_review', 4 | 'block_user', 5 | 'unblock_user' 6 | ); 7 | 8 | CREATE TYPE entity_types AS ENUM ( 9 | 'release_group', 10 | 'event', 11 | 'place', 12 | 'work', 13 | 'artist', 14 | 'label', 15 | 'recording', 16 | 'bb_edition_group', 17 | 'bb_literary_work', 18 | 'bb_author', 19 | 'bb_series' 20 | ); 21 | -------------------------------------------------------------------------------- /admin/config.sh.ctmpl: -------------------------------------------------------------------------------- 1 | {{- define "KEY" -}} 2 | {{ key (printf "docker-server-configs/CB/data-dumps-config.json/%s" .) }} 3 | {{- end -}} 4 | #!/bin/bash 5 | 6 | # rsync to FTP server configuration 7 | RSYNC_FULLEXPORT_HOST="{{template "KEY" "rsync_fullexport_host"}}" 8 | RSYNC_FULLEXPORT_PORT="{{template "KEY" "rsync_fullexport_port"}}" 9 | RSYNC_FULLEXPORT_DIR="/data/dumps" 10 | RSYNC_FULLEXPORT_KEY='/home/critiquebrainz/.ssh/rsync-critiquebrainz-dumps' 11 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/test_moderators.py: -------------------------------------------------------------------------------- 1 | from flask import current_app 2 | 3 | from critiquebrainz.frontend.testing import FrontendTestCase 4 | 5 | 6 | class ModeratorsTestCase(FrontendTestCase): 7 | 8 | def test_moderators(self): 9 | response = self.client.get('/moderators/') 10 | self.assert200(response) 11 | for admin in current_app.config['ADMINS']: 12 | self.assertIn(admin, response.data.decode()) 13 | -------------------------------------------------------------------------------- /docker/push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Build image from the currently checked out version of CritiqueBrainz 4 | # and push it to the Docker Hub, with an optional tag (by default "beta"). 5 | # 6 | # Usage: 7 | # $ ./push.sh [tag] 8 | 9 | cd "$(dirname "${BASH_SOURCE[0]}")/../" 10 | 11 | TAG=${1:-beta} 12 | docker build -t metabrainz/critiquebrainz:$TAG \ 13 | --build-arg GIT_COMMIT_SHA=$(git rev-parse HEAD) . 14 | docker push metabrainz/critiquebrainz:$TAG 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # Virtual environment 6 | env 7 | venv 8 | /build 9 | 10 | # Node 11 | /node_modules 12 | 13 | # Logs 14 | *.log 15 | pip-log.txt 16 | pip-delete-this-directory.txt 17 | 18 | # Application data 19 | /data 20 | 21 | # Test results 22 | htmlcov 23 | .coverage 24 | /reports 25 | /.cache 26 | 27 | .transifexrc 28 | 29 | # Configuration 30 | /consul_config.py 31 | /custom_config.py 32 | -------------------------------------------------------------------------------- /critiquebrainz/data/mixins.py: -------------------------------------------------------------------------------- 1 | from flask import current_app 2 | from flask_login import UserMixin, AnonymousUserMixin 3 | 4 | 5 | class AdminMixin(UserMixin): 6 | """Allows a method to check if the current user is admin.""" 7 | 8 | def is_admin(self): 9 | return self.musicbrainz_username in current_app.config['ADMINS'] 10 | 11 | 12 | class AnonymousUser(AnonymousUserMixin): 13 | 14 | @staticmethod 15 | def is_admin(): 16 | return False 17 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/nav-vertical-align.less: -------------------------------------------------------------------------------- 1 | // Navbar vertical align 2 | // 3 | // Vertically center elements in the navbar. 4 | // Example: an element has a height of 30px, so write out `.navbar-vertical-align(30px);` to calculate the appropriate top margin. 5 | 6 | .navbar-vertical-align(@element-height) { 7 | margin-top: ((@navbar-height - @element-height) / 2); 8 | margin-bottom: ((@navbar-height - @element-height) / 2); 9 | } 10 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/responsive-visibility.less: -------------------------------------------------------------------------------- 1 | // Responsive utilities 2 | 3 | // 4 | // More easily include all the states for responsive-utilities.less. 5 | .responsive-visibility() { 6 | display: block !important; 7 | table& { display: table; } 8 | tr& { display: table-row !important; } 9 | th&, 10 | td& { display: table-cell !important; } 11 | } 12 | 13 | .responsive-invisibility() { 14 | display: none !important; 15 | } 16 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/test_release.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from critiquebrainz.frontend.testing import FrontendTestCase 3 | 4 | 5 | class ReleaseViewsTestCase(FrontendTestCase): 6 | 7 | def test_release_page(self): 8 | response = self.client.get("/release/3b5e9a42-7e0f-4a3a-935e-6231f9292126") 9 | self.assertEqual(response.status_code, 301) 10 | self.assertEqual(response.location, "/release-group/17fbcc66-f03b-4b24-9e77-0368d385e274") 11 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - master 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | # Drafts your next Release notes as Pull Requests are merged into "master" 14 | - uses: release-drafter/release-drafter@v5 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /docker/rc.local: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Hello, this is rc.local. CONTAINER_NAME is $CONTAINER_NAME, and DEPLOY_ENV is $DEPLOY_ENV" 4 | 5 | if [ "${CONTAINER_NAME}" = "critiquebrainz-web-${DEPLOY_ENV}" ] 6 | then 7 | echo "starting uwsgi" 8 | rm -f /etc/service/uwsgi/down 9 | elif [ "${CONTAINER_NAME}" = "critiquebrainz-cron-${DEPLOY_ENV}" ] 10 | then 11 | echo "starting cron" 12 | rm -f /etc/service/cron/down 13 | rm -f /etc/service/cron-config/down 14 | fi 15 | 16 | exit 0 17 | -------------------------------------------------------------------------------- /admin/sql/drop_tables.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | DROP TABLE IF EXISTS comment_revision; 3 | DROP TABLE IF EXISTS comment; 4 | DROP TABLE IF EXISTS vote; 5 | DROP TABLE IF EXISTS spam_report; 6 | DROP TABLE IF EXISTS revision; 7 | DROP TABLE IF EXISTS oauth_grant; 8 | DROP TABLE IF EXISTS oauth_token; 9 | DROP TABLE IF EXISTS oauth_client; 10 | DROP TABLE IF EXISTS moderation_log; 11 | DROP TABLE IF EXISTS review; 12 | DROP TABLE IF EXISTS "user"; 13 | DROP TABLE IF EXISTS license; 14 | DROP TABLE IF EXISTS avg_rating; 15 | COMMIT; 16 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/test_work.py: -------------------------------------------------------------------------------- 1 | from critiquebrainz.frontend.testing import FrontendTestCase 2 | 3 | 4 | class WorkViewsTestCase(FrontendTestCase): 5 | 6 | def test_work_page(self): 7 | response = self.client.get('/work/239389e0-305f-34fc-9147-d5c40332d112') 8 | self.assert200(response) 9 | self.assertIn('Piano Trio in A minor', str(response.data)) 10 | 11 | response = self.client.get('/work/239389e0-305f-34fc-9147-d5c403324444') 12 | self.assert404(response) 13 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/test_event.py: -------------------------------------------------------------------------------- 1 | from critiquebrainz.frontend.testing import FrontendTestCase 2 | 3 | class EventViewsTestCase(FrontendTestCase): 4 | 5 | def test_event_page(self): 6 | response = self.client.get('/event/fe39727a-3d21-4066-9345-3970cbd6cca4') 7 | self.assert200(response) 8 | self.assertIn('Nine Inch Nails at Arena Riga', str(response.data)) 9 | 10 | response = self.client.get('/event/fe39727a-3d21-4066-9345-3970cbd66666') 11 | self.assert404(response) 12 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /.git* 2 | 3 | # Docker configuration 4 | 5 | # Byte-compiled / optimized / DLL files 6 | **/__pycache__/ 7 | **/*.py[cod] 8 | 9 | # Virtual environment 10 | /env*/ 11 | /venv*/ 12 | /build*/ 13 | 14 | # Node 15 | /node_modules/ 16 | 17 | # Logs 18 | **/*.log 19 | **/pip-log.txt 20 | **/pip-delete-this-directory.txt 21 | 22 | # Application data 23 | /data/ 24 | 25 | # Configuration 26 | /consul_config.py 27 | /custom_config.py 28 | 29 | # Test results 30 | **/htmlcov/ 31 | **/.coverage 32 | 33 | **/.transifexrc 34 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/modify/bb_author.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
{{ _('Author') }}
4 |
5 | {{ entity['name'] }} 6 |
7 |
{{ _('Type') }}
8 |
9 | {{ entity['author_type'] is defined and entity['author_type'] or '-' }} 10 |
11 | {% block more_info %} {# Information like creation date, votes etc. #} 12 | {% endblock %} 13 |
14 |
15 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/modify/bb_literary_work.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
{{ _('Literary Work') }}
4 |
5 | {{ entity['name'] }} 6 |
7 |
{{ _('Type') }}
8 |
{{ entity['work_type'] is defined and entity['work_type'] or '-' }}
9 | {% block more_info %} 10 | {# Information like creation date, votes etc. #} 11 | {% endblock %} 12 |
13 |
14 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/testing.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from critiquebrainz.frontend import create_app 4 | from critiquebrainz.testing import ServerTestCase 5 | from critiquebrainz.ws.oauth import oauth 6 | 7 | 8 | class FrontendTestCase(ServerTestCase): 9 | 10 | @classmethod 11 | def create_app(cls): 12 | app = create_app( 13 | config_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', '..', 'test_config.py') 14 | ) 15 | oauth.init_app(app) 16 | app.config['TESTING'] = True 17 | return app 18 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/modify/bb_series.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
{{ _('Series') }}
4 |
5 | {{ entity['name'] }} 6 |
7 |
{{ _('Type') }}
8 |
9 | {{ entity['series_type'] is defined and 10 | entity['series_type'] or '-' }} 11 |
12 | {% block more_info %} {# Information like creation date, votes etc. #} 13 | {% endblock %} 14 |
15 |
16 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/bookbrainz_db/__init__.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.pool import NullPool 3 | 4 | DEFAULT_CACHE_EXPIRATION = 12 * 60 * 60 # seconds (12 hours) 5 | bb_engine = None 6 | 7 | 8 | def init_db_engine(connect_str): 9 | global bb_engine 10 | bb_engine = create_engine(connect_str, poolclass=NullPool) 11 | 12 | 13 | def run_sql_script(sql_file_path): 14 | with open(sql_file_path) as sql: 15 | connection = bb_engine.connect() 16 | connection.execute(sql.read()) 17 | connection.close() 18 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/modify/bb_edition_group.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
{{ _('Edition Group') }}
4 |
5 | {{ entity['name'] }} 6 |
7 |
{{ _('Type') }}
8 |
{{ entity['edition_group_type'] is defined and entity['edition_group_type'] or '-' }}
9 | {% block more_info %} 10 | {# Information like creation date, votes etc. #} 11 | {% endblock %} 12 |
13 |
-------------------------------------------------------------------------------- /critiquebrainz/frontend/static/images/placeholder_author.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/images/placeholder_disc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/pagination.less: -------------------------------------------------------------------------------- 1 | // Pagination 2 | 3 | .pagination-size(@padding-vertical; @padding-horizontal; @font-size; @border-radius) { 4 | > li { 5 | > a, 6 | > span { 7 | padding: @padding-vertical @padding-horizontal; 8 | font-size: @font-size; 9 | } 10 | &:first-child { 11 | > a, 12 | > span { 13 | .border-left-radius(@border-radius); 14 | } 15 | } 16 | &:last-child { 17 | > a, 18 | > span { 19 | .border-right-radius(@border-radius); 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/common.html: -------------------------------------------------------------------------------- 1 | {% set rating_script %} 2 | 3 | 13 | 14 | {% endset %} 15 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/login/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}{{ _('Sign in') }} - CritiqueBrainz{% endblock %} 4 | 5 | {% block content %} 6 |

{{ _('Sign in') }}

7 |

{{ _('To sign in, use your MusicBrainz account, and authorize CritiqueBrainz to access your profile data.') }}

8 |
9 | 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/border-radius.less: -------------------------------------------------------------------------------- 1 | // Single side border-radius 2 | 3 | .border-top-radius(@radius) { 4 | border-top-right-radius: @radius; 5 | border-top-left-radius: @radius; 6 | } 7 | .border-right-radius(@radius) { 8 | border-bottom-right-radius: @radius; 9 | border-top-right-radius: @radius; 10 | } 11 | .border-bottom-radius(@radius) { 12 | border-bottom-right-radius: @radius; 13 | border-bottom-left-radius: @radius; 14 | } 15 | .border-left-radius(@radius) { 16 | border-bottom-left-radius: @radius; 17 | border-top-left-radius: @radius; 18 | } 19 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/bookbrainz_db/exceptions.py: -------------------------------------------------------------------------------- 1 | class BBDatabaseException(Exception): 2 | """Base exception for all exceptions related to BookBrainz database""" 3 | pass 4 | 5 | 6 | class InvalidTypeError(BBDatabaseException): 7 | """Exception related to wrong type in present functions""" 8 | pass 9 | 10 | 11 | class InvalidIncludeError(BBDatabaseException): 12 | """Exception related to wrong includes in present functions""" 13 | pass 14 | 15 | 16 | class NoDataFoundException(BBDatabaseException): 17 | """Exception to be raised when no data has been found""" 18 | pass 19 | 20 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/revision_results.html: -------------------------------------------------------------------------------- 1 | {% for result in results %} 2 | 3 | {{ _('Revision %(number)s', number=result[0]+1) }} 4 | {{ result[1]['timestamp'] | date }} 5 | {{ votes[result[1]['id']]['positive'] }}/{{ votes[result[1]['id']]['negative'] }} 6 | {% if count > 1 %} 7 | 8 | 9 | 10 | 11 | {% endif %} 12 | 13 | {% endfor %} 14 | -------------------------------------------------------------------------------- /admin/functions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | retry() { 4 | local attempts_remaining=5 5 | local delay=15 6 | while true; do 7 | "$@" 8 | status=$? 9 | if [[ $status -eq 0 ]]; then 10 | break 11 | fi 12 | let 'attempts_remaining -= 1' 13 | if [[ $attempts_remaining -gt 0 ]]; then 14 | echo "Command failed with exit status $status; retrying in $delay seconds" 15 | sleep $delay 16 | let 'delay *= 2' 17 | else 18 | echo 'Failed to execute command after 5 attempts' 19 | break 20 | fi 21 | done 22 | } 23 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/bookbrainz-16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os.path 3 | 4 | MANIFEST_PATH = os.path.join(os.path.dirname(__file__), "static", "build", "manifest.json") 5 | 6 | manifest_content = {} 7 | 8 | 9 | def read_manifest(): 10 | if os.path.isfile(MANIFEST_PATH): 11 | with open(MANIFEST_PATH) as manifest_file: 12 | global manifest_content 13 | manifest_content = json.load(manifest_file) 14 | 15 | 16 | def get_static_path(resource_name): 17 | if resource_name not in manifest_content: 18 | return "/static/%s" % resource_name 19 | return "/static/build/%s" % manifest_content[resource_name] 20 | -------------------------------------------------------------------------------- /admin/schema_changes/14.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | -- there was a bug in the schema change that added the published_on column 4 | -- the query was incorrect and the constraint wasn't added, leading to bad 5 | -- data dumps. This should fix it. 6 | UPDATE review ro 7 | SET published_on = (SELECT MAX(rv.timestamp) FROM review r JOIN revision rv ON r.id = rv.review_id WHERE r.id = ro.id) 8 | WHERE is_draft = 'f' AND published_on IS NULL; 9 | 10 | ALTER TABLE review ADD CONSTRAINT published_on_null_for_drafts_and_not_null_for_published_reviews 11 | CHECK ((is_draft = 't' AND published_on IS NULL) OR (is_draft = 'f' AND published_on IS NOT NULL)); 12 | 13 | COMMIT; 14 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/modify/place.html: -------------------------------------------------------------------------------- 1 | {% from 'macros.html' import show_life_span with context %} 2 | 3 |
4 |
5 |
{{ _('Place') }}
6 |
7 | {{ entity['name'] }} 8 | 9 | {{ show_life_span(entity, False) }} 10 | 11 |
12 |
{{ _('Location') }}
13 |
{{ entity['area']['name'] or '-' }}
14 | {% block more_info %} 15 | {# Information like creation date, votes etc. #} 16 | {% endblock %} 17 |
18 |
19 | -------------------------------------------------------------------------------- /docker/cron/crontab: -------------------------------------------------------------------------------- 1 | PATH=/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin 2 | 3 | # Database backup creation 4 | 10 00 * * * critiquebrainz /usr/local/bin/python /code/manage.py dump full_db -l /data/backups -r >> /var/log/dump_backup.log 2>&1 5 | 6 | # Public MB-style dump creation 7 | 15 00 * * * critiquebrainz /usr/local/bin/python /code/manage.py dump public -l /data/dumps/dump -r >> /var/log/public_dump_create.log 2>&1 8 | 9 | # JSON dump creation 10 | 20 00 * * * critiquebrainz /usr/local/bin/python /code/manage.py dump json -l /data/dumps/json -r >> /var/log/json_dump_create.log 2>&1 11 | 12 | # Copy everything over... 13 | 45 00 * * * critiquebrainz /code/admin/rsync-dump-files.sh 14 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/modify/artist.html: -------------------------------------------------------------------------------- 1 | {% from 'macros.html' import show_life_span with context %} 2 | 3 |
4 |
5 |
{{ _('Artist') }}
6 |
7 | {{ entity['name'] }} 8 | 9 | {{ show_life_span(entity, False) }} 10 | 11 |
12 |
{{ _('Type') }}
13 |
{{ entity['type'] is defined and entity['type'] or '-' }}
14 | {% block more_info %} 15 | {# Information like creation date, votes etc. #} 16 | {% endblock %} 17 |
18 |
19 | -------------------------------------------------------------------------------- /critiquebrainz/test/utils_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from critiquebrainz import utils 4 | 5 | 6 | class UtilsTestCase(unittest.TestCase): 7 | 8 | def test_generate_string(self): 9 | length = 42 10 | str_1 = utils.generate_string(length) 11 | str_2 = utils.generate_string(length) 12 | 13 | self.assertEqual(len(str_1), length) 14 | self.assertEqual(len(str_2), length) 15 | self.assertNotEqual(str_1, str_2) # Generated strings shouldn't be the same 16 | 17 | def test_validate_uuid(self): 18 | self.assertTrue(utils.validate_uuid("123e4567-e89b-12d3-a456-426655440000")) 19 | self.assertFalse(utils.validate_uuid("not-a-uuid")) 20 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Apart from writing reviews, there are plenty of ways to contribute to the project. 5 | See `CONTRIBUTING.md file `_ 6 | in our GitHub repository for more info about that. 7 | 8 | Structure 9 | --------- 10 | 11 | CritiqueBrainz project is separated into three main packages: data, frontend, and web service (ws). 12 | The data package is used to interact with the database. The frontend provides user-friendly interface 13 | that is available at https://critiquebrainz.org. 14 | 15 | *Here's an overview of the project structure:* 16 | 17 | .. image:: /images/structure.svg 18 | -------------------------------------------------------------------------------- /admin/schema_changes/8.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | ALTER TABLE revision ALTER COLUMN text DROP NOT NULL; 4 | ALTER TABLE revision ADD COLUMN rating SMALLINT; 5 | 6 | ALTER TABLE revision ADD CONSTRAINT revision_rating_check CHECK (rating >= 0 AND rating <=100); 7 | ALTER TABLE revision ADD CONSTRAINT revision_text_rating_both_not_null_together 8 | CHECK (rating is NOT NULL OR text is NOT NULL); 9 | 10 | CREATE TABLE avg_rating ( 11 | entity_id UUID NOT NULL, 12 | entity_type entity_types NOT NULL, 13 | rating SMALLINT NOT NULL CHECK (rating >= 0 AND rating <= 100), 14 | count INTEGER NOT NULL, 15 | PRIMARY KEY (entity_id, entity_type) 16 | ); 17 | 18 | COMMIT; 19 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/panels.less: -------------------------------------------------------------------------------- 1 | // Panels 2 | 3 | .panel-variant(@border; @heading-text-color; @heading-bg-color; @heading-border) { 4 | border-color: @border; 5 | 6 | & > .panel-heading { 7 | color: @heading-text-color; 8 | background-color: @heading-bg-color; 9 | border-color: @heading-border; 10 | 11 | + .panel-collapse > .panel-body { 12 | border-top-color: @border; 13 | } 14 | .badge { 15 | color: @heading-bg-color; 16 | background-color: @heading-text-color; 17 | } 18 | } 19 | & > .panel-footer { 20 | + .panel-collapse > .panel-body { 21 | border-bottom-color: @border; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/wells.less: -------------------------------------------------------------------------------- 1 | // 2 | // Wells 3 | // -------------------------------------------------- 4 | 5 | 6 | // Base class 7 | .well { 8 | min-height: 20px; 9 | padding: 19px; 10 | margin-bottom: 20px; 11 | background-color: @well-bg; 12 | border: 1px solid @well-border; 13 | border-radius: @border-radius-base; 14 | .box-shadow(inset 0 1px 1px rgba(0,0,0,.05)); 15 | blockquote { 16 | border-color: #ddd; 17 | border-color: rgba(0,0,0,.15); 18 | } 19 | } 20 | 21 | // Sizes 22 | .well-lg { 23 | padding: 24px; 24 | border-radius: @border-radius-large; 25 | } 26 | .well-sm { 27 | padding: 9px; 28 | border-radius: @border-radius-small; 29 | } 30 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | CritiqueBrainz 2 | ============== 3 | 4 | Hello there! Thanks for your interest in CritiqueBrainz project. It is a 5 | repository for Creative Commons licensed reviews for music, books and other 6 | things. This project is based on data from MusicBrainz - open music encyclopedia, 7 | and BookBrainz - open book encyclopedia. 8 | Everyone - including you - can participate and contribute. 9 | 10 | This is an open source project. Source code is available 11 | `on GitHub `_. 12 | 13 | 14 | Contents 15 | -------- 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | 20 | intro 21 | api 22 | contributing 23 | db 24 | export 25 | translation 26 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/release.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, redirect 2 | from flask_babel import gettext 3 | from werkzeug.exceptions import NotFound 4 | 5 | import critiquebrainz.frontend.external.musicbrainz_db.release as mb_release 6 | 7 | release_bp = Blueprint('release', __name__) 8 | 9 | 10 | @release_bp.route('/') 11 | def entity(id): 12 | id = str(id) 13 | release_data = mb_release.get_release_by_mbid(id) 14 | if release_data: 15 | group_id = release_data['release-group']['mbid'] 16 | url = '/release-group/' + str(group_id) 17 | return redirect(url, 301) 18 | 19 | raise NotFound(gettext("Sorry, we couldn't find a release with that MusicBrainz ID.")) 20 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/list-group.less: -------------------------------------------------------------------------------- 1 | // List Groups 2 | 3 | .list-group-item-variant(@state; @background; @color) { 4 | .list-group-item-@{state} { 5 | color: @color; 6 | background-color: @background; 7 | 8 | a& { 9 | color: @color; 10 | 11 | .list-group-item-heading { 12 | color: inherit; 13 | } 14 | 15 | &:hover, 16 | &:focus { 17 | color: @color; 18 | background-color: darken(@background, 5%); 19 | } 20 | &.active, 21 | &.active:hover, 22 | &.active:focus { 23 | color: #fff; 24 | background-color: @color; 25 | border-color: @color; 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/hide-text.less: -------------------------------------------------------------------------------- 1 | // CSS image replacement 2 | // 3 | // Heads up! v3 launched with with only `.hide-text()`, but per our pattern for 4 | // mixins being reused as classes with the same name, this doesn't hold up. As 5 | // of v3.0.1 we have added `.text-hide()` and deprecated `.hide-text()`. 6 | // 7 | // Source: https://github.com/h5bp/html5-boilerplate/commit/aa0396eae757 8 | 9 | // Deprecated as of v3.0.1 (will be removed in v4) 10 | .hide-text() { 11 | font: ~"0/0" a; 12 | color: transparent; 13 | text-shadow: none; 14 | background-color: transparent; 15 | border: 0; 16 | } 17 | 18 | // New mixin to use as of v3.0.1 19 | .text-hide() { 20 | .hide-text(); 21 | } 22 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/forms/comment.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from flask_babel import lazy_gettext 3 | from wtforms import TextAreaField, StringField 4 | from wtforms.widgets import HiddenInput 5 | from wtforms.validators import Length 6 | 7 | 8 | class CommentEditForm(FlaskForm): 9 | state = StringField(widget=HiddenInput(), default='publish') 10 | text = TextAreaField( 11 | lazy_gettext("Add a comment..."), 12 | validators=[Length(min= 1, message=lazy_gettext("Comment must not be empty!"))] 13 | ) 14 | review_id = StringField(widget=HiddenInput()) 15 | 16 | def __init__(self, review_id=None, **kwargs): 17 | kwargs['review_id'] = review_id 18 | FlaskForm.__init__(self, **kwargs) 19 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/modify/label.html: -------------------------------------------------------------------------------- 1 | {% from 'macros.html' import show_life_span with context %} 2 | 3 |
4 |
5 |
{{ _('Label') }}
6 |
7 | {{ entity['name'] }} 8 | 9 | {{ show_life_span(entity, False) }} 10 | 11 |
12 |
{{ _('Type') }}
13 |
{{ entity['type'] or '-' }}
14 |
{{ _('Country') }}
15 |
{{ entity['area'] or '-' }}
16 | {% block more_info %} 17 | {# Information like creation date, votes etc. #} 18 | {% endblock %} 19 |
20 |
21 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/clearfix.less: -------------------------------------------------------------------------------- 1 | // Clearfix 2 | // 3 | // For modern browsers 4 | // 1. The space content is one way to avoid an Opera bug when the 5 | // contenteditable attribute is included anywhere else in the document. 6 | // Otherwise it causes space to appear at the top and bottom of elements 7 | // that are clearfixed. 8 | // 2. The use of `table` rather than `block` is only necessary if using 9 | // `:before` to contain the top-margins of child elements. 10 | // 11 | // Source: http://nicolasgallagher.com/micro-clearfix-hack/ 12 | 13 | .clearfix() { 14 | &:before, 15 | &:after { 16 | content: " "; // 1 17 | display: table; // 2 18 | } 19 | &:after { 20 | clear: both; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/breadcrumbs.less: -------------------------------------------------------------------------------- 1 | // 2 | // Breadcrumbs 3 | // -------------------------------------------------- 4 | 5 | 6 | .breadcrumb { 7 | padding: @breadcrumb-padding-vertical @breadcrumb-padding-horizontal; 8 | margin-bottom: @line-height-computed; 9 | list-style: none; 10 | background-color: @breadcrumb-bg; 11 | border-radius: @border-radius-base; 12 | 13 | > li { 14 | display: inline-block; 15 | 16 | + li:before { 17 | content: "@{breadcrumb-separator}\00a0"; // Unicode space added since inline-block means non-collapsing white-space 18 | padding: 0 5px; 19 | color: @breadcrumb-color; 20 | } 21 | } 22 | 23 | > .active { 24 | color: @breadcrumb-active-color; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/responsive-embed.less: -------------------------------------------------------------------------------- 1 | // Embeds responsive 2 | // 3 | // Credit: Nicolas Gallagher and SUIT CSS. 4 | 5 | .embed-responsive { 6 | position: relative; 7 | display: block; 8 | height: 0; 9 | padding: 0; 10 | overflow: hidden; 11 | 12 | .embed-responsive-item, 13 | iframe, 14 | embed, 15 | object, 16 | video { 17 | position: absolute; 18 | top: 0; 19 | left: 0; 20 | bottom: 0; 21 | height: 100%; 22 | width: 100%; 23 | border: 0; 24 | } 25 | } 26 | 27 | // Modifier class for 16:9 aspect ratio 28 | .embed-responsive-16by9 { 29 | padding-bottom: 56.25%; 30 | } 31 | 32 | // Modifier class for 4:3 aspect ratio 33 | .embed-responsive-4by3 { 34 | padding-bottom: 75%; 35 | } 36 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/test_markdown.py: -------------------------------------------------------------------------------- 1 | from critiquebrainz.frontend.testing import FrontendTestCase 2 | 3 | from critiquebrainz.frontend.views import markdown 4 | 5 | class MarkdownTestCase(FrontendTestCase): 6 | 7 | def test_link_attrs(self): 8 | md = "This is [text with link](https://example.com) and more" 9 | html = markdown.format_markdown_as_safe_html(md) 10 | 11 | assert """""" in html 12 | 13 | def test_inline_link_attrs(self): 14 | md = "This is a url: https://example.net, and more" 15 | html = markdown.format_markdown_as_safe_html(md) 16 | 17 | assert """https://example.net""" in html 18 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/images/placeholder_edition_group.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/profile/delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}{{ _('Delete account') }} - CritiqueBrainz{% endblock %} 4 | 5 | {% block content %} 6 |

{{ _('Delete account') }}

7 |
8 |
9 |
10 |

{{ _('Are you sure you want to delete your account?') }}

11 |

{{ _('All your data (i.e. reviews, votes, reports) will be purged. You cannot undo this action.') }}

12 |
13 |
14 | 15 | {{ _('No') }} 16 |
17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/__init__.py: -------------------------------------------------------------------------------- 1 | import critiquebrainz.db.avg_rating as db_avg_rating 2 | import critiquebrainz.db.exceptions as db_exceptions 3 | 4 | ARTIST_REVIEWS_LIMIT = 5 5 | AUTHOR_REVIEWS_LIMIT = 10 6 | EDITION_GROUP_REVIEWS_LIMIT = 10 7 | LABEL_REVIEWS_LIMIT = 5 8 | LITERARY_WORK_REVIEWS_LIMIT = 10 9 | PLACE_REVIEW_LIMIT = 5 10 | SERIES_REVIEWS_LIMIT = 10 11 | WORK_REVIEWS_LIMIT = 5 12 | RECORDING_REVIEWS_LIMIT = 10 13 | BROWSE_LITERARY_WORK_LIMIT = 10 14 | BROWSE_RELEASE_GROUPS_LIMIT = 20 15 | BROWSE_EVENTS_LIMIT = 15 16 | BROWSE_RECORDING_LIMIT = 10 17 | 18 | 19 | def get_avg_rating(entity_id, entity_type): 20 | """Retrieve average rating""" 21 | try: 22 | return db_avg_rating.get(entity_id, entity_type) 23 | except db_exceptions.NoDataFoundException: 24 | return None 25 | -------------------------------------------------------------------------------- /critiquebrainz/data/test/utils_test.py: -------------------------------------------------------------------------------- 1 | from critiquebrainz.data import utils 2 | from critiquebrainz.data.testing import DataTestCase 3 | 4 | 5 | class DataUtilsTestCase(DataTestCase): 6 | 7 | def test_explode_db_uri(self): 8 | uri = "postgresql://cb_user:cb_password@localhost:5432/cb" 9 | hostname, port, db_name, username, password = utils.explode_db_uri(uri) 10 | 11 | self.assertEqual(hostname, "localhost") 12 | self.assertEqual(port, 5432) 13 | self.assertEqual(db_name, "cb") 14 | self.assertEqual(username, "cb_user") 15 | self.assertEqual(password, "cb_password") 16 | 17 | def test_slugify(self): 18 | self.assertEqual(utils.slugify(u'CC BY-NC-SA 3.0'), 'cc-by-nc-sa-30') 19 | self.assertEqual(utils.slugify(u'CC BY-SA 3.0'), 'cc-by-sa-30') 20 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/musicbrainz_db/work.py: -------------------------------------------------------------------------------- 1 | from brainzutils import cache 2 | from brainzutils.musicbrainz_db import work as db 3 | 4 | from critiquebrainz.frontend.external.musicbrainz_db import DEFAULT_CACHE_EXPIRATION 5 | 6 | 7 | def get_work_by_mbid(mbid): 8 | """Get work with MusicBrainz ID. 9 | 10 | Args: 11 | mbid (uuid): MBID(gid) of the work. 12 | Returns: 13 | Dictionary containing the work information 14 | """ 15 | key = cache.gen_key('work', mbid) 16 | work = cache.get(key) 17 | if not work: 18 | work = db.get_work_by_mbid( 19 | mbid, 20 | includes=['artist-rels', 'recording-rels'], 21 | ) 22 | if not work: 23 | return None 24 | cache.set(key, work, DEFAULT_CACHE_EXPIRATION) 25 | return work 26 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/moderators/moderators.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}{{ _('Moderators') }} - CritiqueBrainz{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

{{ _('Moderators') }}

8 |

{{ _('These users respond to reports and make sure that spammers don’t spam.') }}

9 |
    10 | {% for mod in moderators %} 11 |
  • 12 | {% if mod['critiquebrainz_id'] is defined and mod['critiquebrainz_id'] %} 13 | 14 | {{ mod['musicbrainz_username'] }} 15 | 16 | {% else %} 17 | {{ mod['musicbrainz_username'] }} 18 | {% endif %} 19 |
  • 20 | {% endfor %} 21 |
22 |
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/modify/event.html: -------------------------------------------------------------------------------- 1 | {% from 'macros.html' import show_life_span with context %} 2 | 3 |
4 |
5 |
{{ _('Event') }}
6 |
7 | {{ entity['name'] }} 8 | 9 | {{ show_life_span(entity, False) }} 10 | 11 |
12 |
{{ _('Place') }}
13 | {% if entity['place-relation-list'] is not defined %} 14 |
{{ _('Unknown place') }}
15 | {% else %} 16 |
{{ entity['place-relation-list'][0]['place']['name']}}
17 | {% endif %} 18 | {% block more_info %} 19 | {# Information like creation date, votes etc. #} 20 | {% endblock %} 21 |
22 |
23 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/musicbrainz_db/release.py: -------------------------------------------------------------------------------- 1 | from brainzutils import cache 2 | from brainzutils.musicbrainz_db import release as db 3 | 4 | from critiquebrainz.frontend.external.musicbrainz_db import DEFAULT_CACHE_EXPIRATION 5 | 6 | 7 | def get_release_by_mbid(mbid): 8 | """Get release with MusicBrainz ID. 9 | 10 | Args: 11 | mbid (uuid): MBID(gid) of the release. 12 | Returns: 13 | Dictionary containing the release information 14 | """ 15 | key = cache.gen_key('release', mbid) 16 | release = cache.get(key) 17 | if not release: 18 | release = db.get_release_by_mbid( 19 | mbid, 20 | includes=['media', 'release-groups'], 21 | ) 22 | if not release: 23 | return None 24 | cache.set(key, release, DEFAULT_CACHE_EXPIRATION) 25 | return release 26 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/images/placeholder_literary_work.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/component-animations.less: -------------------------------------------------------------------------------- 1 | // 2 | // Component animations 3 | // -------------------------------------------------- 4 | 5 | // Heads up! 6 | // 7 | // We don't use the `.opacity()` mixin here since it causes a bug with text 8 | // fields in IE7-8. Source: https://github.com/twbs/bootstrap/pull/3552. 9 | 10 | .fade { 11 | opacity: 0; 12 | .transition(opacity .15s linear); 13 | &.in { 14 | opacity: 1; 15 | } 16 | } 17 | 18 | .collapse { 19 | display: none; 20 | 21 | &.in { display: block; } 22 | tr&.in { display: table-row; } 23 | tbody&.in { display: table-row-group; } 24 | } 25 | 26 | .collapsing { 27 | position: relative; 28 | height: 0; 29 | overflow: hidden; 30 | .transition-property(~"height, visibility"); 31 | .transition-duration(.35s); 32 | .transition-timing-function(ease); 33 | } 34 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/table-row.less: -------------------------------------------------------------------------------- 1 | // Tables 2 | 3 | .table-row-variant(@state; @background) { 4 | // Exact selectors below required to override `.table-striped` and prevent 5 | // inheritance to nested tables. 6 | .table > thead > tr, 7 | .table > tbody > tr, 8 | .table > tfoot > tr { 9 | > td.@{state}, 10 | > th.@{state}, 11 | &.@{state} > td, 12 | &.@{state} > th { 13 | background-color: @background; 14 | } 15 | } 16 | 17 | // Hover states for `.table-hover` 18 | // Note: this is not available for cells or rows within `thead` or `tfoot`. 19 | .table-hover > tbody > tr { 20 | > td.@{state}:hover, 21 | > th.@{state}:hover, 22 | &.@{state}:hover > td, 23 | &:hover > .@{state}, 24 | &.@{state}:hover > th { 25 | background-color: darken(@background, 5%); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/musicbrainz_db/recording.py: -------------------------------------------------------------------------------- 1 | from brainzutils import cache 2 | from brainzutils.musicbrainz_db import recording as db 3 | 4 | from critiquebrainz.frontend.external.musicbrainz_db import DEFAULT_CACHE_EXPIRATION 5 | 6 | 7 | def get_recording_by_mbid(mbid): 8 | """Get recording with MusicBrainz ID. 9 | 10 | Args: 11 | mbid (uuid): MBID(gid) of the recording. 12 | Returns: 13 | Dictionary containing the recording information 14 | """ 15 | key = cache.gen_key('recording', mbid) 16 | recording = cache.get(key) 17 | if not recording: 18 | recording = db.get_recording_by_mbid( 19 | mbid, 20 | includes=['artists', 'work-rels', 'url-rels'], 21 | ) 22 | if not recording: 23 | return None 24 | cache.set(key, recording, DEFAULT_CACHE_EXPIRATION) 25 | return recording 26 | -------------------------------------------------------------------------------- /admin/schema_changes/5.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | ALTER TABLE "user" ADD COLUMN is_blocked boolean NOT NULL DEFAULT FALSE; 4 | 5 | CREATE TYPE action_types AS ENUM ( 6 | 'hide_review', 7 | 'block_user' 8 | ); 9 | 10 | CREATE TABLE moderation_log ( 11 | id SERIAL NOT NULL, 12 | admin_id UUID NOT NULL, 13 | user_id UUID, 14 | review_id UUID, 15 | action action_types NOT NULL, 16 | timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL, 17 | reason VARCHAR NOT NULL, 18 | PRIMARY KEY (id), 19 | FOREIGN KEY(admin_id) REFERENCES "user" (id) ON DELETE CASCADE, 20 | FOREIGN KEY(user_id) REFERENCES "user" (id) ON DELETE CASCADE, 21 | FOREIGN KEY(review_id) REFERENCES review (id) ON DELETE CASCADE 22 | ); 23 | 24 | ALTER TABLE spam_report ADD COLUMN is_archived boolean NOT NULL DEFAULT FALSE; 25 | ALTER TABLE review ADD COLUMN is_hidden boolean NOT NULL DEFAULT FALSE; 26 | 27 | COMMIT; 28 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/modify/recording.html: -------------------------------------------------------------------------------- 1 | {% if entity is not defined %} 2 | {% set entity = review.entity_id | entity_details(entity_type=entity_type) %} 3 | {% endif %} 4 |
5 |
6 |
{{ _('Recording') }}
7 |
8 | {{ entity['name'] }} 9 |
10 |
{{ _('Length') }}
11 |
12 | {% if entity['length'] is defined and entity['length'] %} 13 | {{ entity['length'] | track_length }} 14 | {% else %} 15 | - 16 | {% endif %} 17 |
18 |
{{ _('Artist') }}
19 |
20 | {{ entity['artist-credit-phrase'] or '-' }} 21 |
22 | {% block more_info %} 23 | {# Information like creation date, votes etc. #} 24 | {% endblock %} 25 |
26 |
27 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/modify/release_group.html: -------------------------------------------------------------------------------- 1 | {% from 'macros.html' import cover_art with context %} 2 | 3 |
4 |
5 |
{{ _('Artist') }}
{{ entity['artist-credit-phrase'] }}
6 |
{{ _('Release group') }}
7 |
8 | {{ entity['title'] }} 9 | {% if entity['first-release-date'] is defined %} 10 | ({{ entity['first-release-date'][:4] }}) 11 | {% endif %} 12 |
13 | {% block more_info %} 14 | {# Information like creation date, votes etc. #} 15 | {% endblock %} 16 |
17 |
18 |
19 | {{ cover_art(entity['mbid'], 'release_group', attributes='class=cover-art style=max-height:120px;max-width:120px;') }} 20 |
21 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/bookbrainz_db/test/bb_relationship_test.py: -------------------------------------------------------------------------------- 1 | from critiquebrainz.frontend.external.bookbrainz_db import relationships 2 | from critiquebrainz.data.testing import DataTestCase 3 | 4 | class BBRelationshipTestCase(DataTestCase): 5 | def setUP(self): 6 | super(BBRelationshipTestCase, self).setUp() 7 | 8 | def test_bb_relationship(self): 9 | relationship = relationships.fetch_relationships(99999999, [relationships.AUTHOR_WORK_AUTHOR_REL_ID]) 10 | self.assertEqual(relationship[0]["label"], "Author") 11 | self.assertEqual(relationship[0]["source_bbid"], "e5c4e68b-bfce-4c77-9ca2-0f0a2d4d09f0") 12 | self.assertEqual(relationship[0]["target_bbid"], "9f49df73-8ee5-4c5f-8803-427c9b216d8f") 13 | 14 | relationship = relationships.fetch_relationships(99999999, [relationships.EDITION_EDITION_GROUP_EDITION_REL_ID]) 15 | self.assertEqual(relationship, []) 16 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/musicbrainz_db/label.py: -------------------------------------------------------------------------------- 1 | from brainzutils import cache 2 | from brainzutils.musicbrainz_db import label as db 3 | 4 | from critiquebrainz.frontend.external.musicbrainz_db import DEFAULT_CACHE_EXPIRATION 5 | from critiquebrainz.frontend.external.relationships import label as label_rel 6 | 7 | 8 | def get_label_by_mbid(mbid): 9 | """Get label with MusicBrainz ID. 10 | 11 | Args: 12 | mbid (uuid): MBID(gid) of the label. 13 | Returns: 14 | Dictionary containing the label information 15 | """ 16 | key = cache.gen_key('label', mbid) 17 | label = cache.get(key) 18 | if not label: 19 | label = db.get_label_by_mbid( 20 | mbid, 21 | includes=['artist-rels', 'url-rels'], 22 | ) 23 | if not label: 24 | return None 25 | cache.set(key, label, DEFAULT_CACHE_EXPIRATION) 26 | return label_rel.process(label) 27 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/test_search.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from critiquebrainz.frontend.testing import FrontendTestCase 4 | 5 | 6 | class SearchViewsTestCase(FrontendTestCase): 7 | 8 | def setUp(self): 9 | super(SearchViewsTestCase, self).setUp() 10 | self.search_results = (1, [{ 11 | 'id': 'b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d', 12 | 'type': 'Group', 13 | 'name': 'The Beatles', 14 | 'sort-name': 'Beatles, The', 15 | 'country': 'GB' 16 | }]) 17 | 18 | @mock.patch('critiquebrainz.frontend.external.musicbrainz.search_artists') 19 | def test_search_page(self, search_artists): 20 | search_artists.return_value = self.search_results 21 | response = self.client.get("/search/?query=The+Beatles&type=artist") 22 | self.assert200(response) 23 | self.assertIn("Beatles, The", str(response.data)) 24 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/musicbrainz_db/artist.py: -------------------------------------------------------------------------------- 1 | from brainzutils import cache 2 | from brainzutils.musicbrainz_db import artist as db 3 | 4 | from critiquebrainz.frontend.external.musicbrainz_db import DEFAULT_CACHE_EXPIRATION 5 | from critiquebrainz.frontend.external.relationships import artist as artist_rel 6 | 7 | 8 | def get_artist_by_mbid(mbid): 9 | """Get artist with MusicBrainz ID. 10 | 11 | Args: 12 | mbid (uuid): MBID(gid) of the artist. 13 | Returns: 14 | Dictionary containing the artist information 15 | """ 16 | key = cache.gen_key('artist', mbid) 17 | artist = cache.get(key) 18 | if not artist: 19 | artist = db.get_artist_by_mbid( 20 | mbid, 21 | includes=['artist-rels', 'url-rels'], 22 | ) 23 | if not artist: 24 | return None 25 | cache.set(key, artist, DEFAULT_CACHE_EXPIRATION) 26 | return artist_rel.process(artist) 27 | -------------------------------------------------------------------------------- /docker/docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | # Docker Compose file for testing 2 | version: "3.4" 3 | services: 4 | 5 | db: 6 | image: postgres:12.3 7 | user: postgres 8 | environment: 9 | POSTGRES_USER: postgres 10 | POSTGRES_PASSWORD: postgres 11 | command: postgres -F 12 | volumes: 13 | - ./pg_custom:/docker-entrypoint-initdb.d/ 14 | 15 | musicbrainz_db: 16 | image: metabrainz/brainzutils-mb-sample-database:schema-27-2022-05-20.0 17 | environment: 18 | POSTGRES_HOST_AUTH_METHOD: trust 19 | 20 | critiquebrainz_redis: 21 | image: redis:4.0-alpine 22 | 23 | critiquebrainz: 24 | build: 25 | context: .. 26 | dockerfile: ./Dockerfile 27 | target: critiquebrainz-dev 28 | volumes: 29 | - ..:/code 30 | depends_on: 31 | - db 32 | - musicbrainz_db 33 | - critiquebrainz_redis 34 | environment: 35 | PGPASSWORD: critiquebrainz 36 | SQLALCHEMY_WARN_20: 1 37 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/close.less: -------------------------------------------------------------------------------- 1 | // 2 | // Close icons 3 | // -------------------------------------------------- 4 | 5 | 6 | .close { 7 | float: right; 8 | font-size: (@font-size-base * 1.5); 9 | font-weight: @close-font-weight; 10 | line-height: 1; 11 | color: @close-color; 12 | text-shadow: @close-text-shadow; 13 | .opacity(.2); 14 | 15 | &:hover, 16 | &:focus { 17 | color: @close-color; 18 | text-decoration: none; 19 | cursor: pointer; 20 | .opacity(.5); 21 | } 22 | 23 | // Additional properties for button version 24 | // iOS requires the button element instead of an anchor tag. 25 | // If you want the anchor version, it requires `href="#"`. 26 | // See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile 27 | button& { 28 | padding: 0; 29 | cursor: pointer; 30 | background: transparent; 31 | border: 0; 32 | -webkit-appearance: none; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/thumbnails.less: -------------------------------------------------------------------------------- 1 | // 2 | // Thumbnails 3 | // -------------------------------------------------- 4 | 5 | 6 | // Mixin and adjust the regular image class 7 | .thumbnail { 8 | display: block; 9 | padding: @thumbnail-padding; 10 | margin-bottom: @line-height-computed; 11 | line-height: @line-height-base; 12 | background-color: @thumbnail-bg; 13 | border: 1px solid @thumbnail-border; 14 | border-radius: @thumbnail-border-radius; 15 | .transition(border .2s ease-in-out); 16 | 17 | > img, 18 | a > img { 19 | &:extend(.img-responsive); 20 | margin-left: auto; 21 | margin-right: auto; 22 | } 23 | 24 | // Add a hover state for linked versions only 25 | a&:hover, 26 | a&:focus, 27 | a&.active { 28 | border-color: @link-color; 29 | } 30 | 31 | // Image captions 32 | .caption { 33 | padding: @thumbnail-caption-padding; 34 | color: @thumbnail-caption-color; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/musicbrainz_db/place.py: -------------------------------------------------------------------------------- 1 | from brainzutils import cache 2 | from brainzutils.musicbrainz_db import place as db 3 | 4 | from critiquebrainz.frontend.external.musicbrainz_db import DEFAULT_CACHE_EXPIRATION 5 | from critiquebrainz.frontend.external.relationships import place as place_rel 6 | 7 | 8 | def get_place_by_mbid(mbid): 9 | """Get place with the MusicBrainz ID. 10 | 11 | Args: 12 | mbid (uuid): MBID(gid) of the place. 13 | Returns: 14 | Dictionary containing the place information. 15 | """ 16 | key = cache.gen_key('place', mbid) 17 | place = cache.get(key) 18 | if not place: 19 | place = db.get_place_by_mbid( 20 | mbid, 21 | includes=['artist-rels', 'place-rels', 'release-group-rels', 'url-rels'], 22 | ) 23 | if not place: 24 | return None 25 | cache.set(key, place, DEFAULT_CACHE_EXPIRATION) 26 | return place_rel.process(place) 27 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/flash.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a helper module for Flask's flash messages. 3 | This module defines constants which represent available categories of flash 4 | messages. These match to categories defined in `print_message` macro in 5 | templates/macros.html file. If you modify macro and/or constants defined there, 6 | make sure that they match. 7 | More information about flash messages is available at 8 | http://flask.pocoo.org/docs/0.10/patterns/flashing/. 9 | """ 10 | from flask import flash 11 | 12 | INFO = "info" # this is a default category 13 | SUCCESS = "success" 14 | WARNING = "warning" 15 | ERROR = "error" 16 | 17 | 18 | def info(message): 19 | flash(message, INFO) 20 | 21 | 22 | def success(message): 23 | flash(message, SUCCESS) 24 | 25 | 26 | def warning(message): 27 | flash(message, WARNING) 28 | 29 | 30 | def warn(message): 31 | """Alias for `warning`.""" 32 | warning(message) 33 | 34 | 35 | def error(message): 36 | flash(message, ERROR) 37 | -------------------------------------------------------------------------------- /scripts/download-import-bookbrainz-dump.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DB_HOSTNAME=db 4 | DB_PORT=5432 5 | DB_USER=bookbrainz 6 | DB_NAME=bookbrainz 7 | DB_PASSWORD=bookbrainz 8 | 9 | DUMP_DIR=/tmp/bookbrainz-dumps 10 | DUMP_FILE=$DUMP_DIR/latest.sql.bz2 11 | 12 | if [ -f $DUMP_FILE ]; then 13 | echo "A bookbrainz dump file, already exists. Using that to import." 14 | echo "To force a re-download of the data, please remove $DUMP_FILE" 15 | else 16 | mkdir -p $DUMP_DIR 17 | curl -o $DUMP_FILE ftp://ftp.musicbrainz.org/pub/musicbrainz/bookbrainz/latest.sql.bz2 18 | if [ $? -ne 0 ] 19 | then 20 | echo "Downloading the bookbrainz data dump failed." 21 | exit $? 22 | fi 23 | fi 24 | 25 | bzcat $DUMP_FILE | PGPASSWORD=$DB_PASSWORD psql -h $DB_HOSTNAME -p $DB_PORT -U $DB_USER -d $DB_NAME 26 | if [ $? -ne 0 ] 27 | then 28 | echo "Importing the bookbrainz database failed." 29 | exit $? 30 | fi 31 | 32 | # Clean up the dump file if it imported correctly. 33 | rm -f $DUMP_FILE 34 | -------------------------------------------------------------------------------- /docs/export.rst: -------------------------------------------------------------------------------- 1 | Exporting data 2 | ============== 3 | 4 | You can create backups including various pieces of data that we store: reviews, 5 | revisions, users, and other stuff. Some parts include private data about users 6 | that is not meant to be shared. 7 | 8 | Creating data dumps 9 | ------------------- 10 | 11 | Below you can find commands that can be used to create backups of different formats. 12 | 13 | Complete database dump *(for PostgreSQL)*:: 14 | 15 | $ docker-compose -f docker/docker-compose.dev.yml run --rm critiquebrainz python3 manage.py dump full_db 16 | 17 | MusicBrainz-style dump public *(no private info)*:: 18 | 19 | $ docker-compose -f docker/docker-compose.dev.yml run --rm critiquebrainz python3 manage.py dump public 20 | 21 | JSON dump with all reviews *(no private info)*:: 22 | 23 | $ docker-compose -f docker/docker-compose.dev.yml run --rm critiquebrainz python3 manage.py dump json 24 | 25 | All commands have rotation feature which can be enabled by passing `-r` argument. 26 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/test_index.py: -------------------------------------------------------------------------------- 1 | from critiquebrainz.frontend import create_app 2 | from critiquebrainz.frontend.testing import FrontendTestCase 3 | 4 | 5 | class ViewsTestCase(FrontendTestCase): 6 | 7 | def test_home_page(self): 8 | response = self.client.get("/") 9 | self.assert200(response) 10 | 11 | def test_404(self): 12 | response = self.client.get("/404") 13 | self.assert404(response) 14 | 15 | def test_guidelines(self): 16 | response = self.client.get("/guidelines") 17 | self.assert200(response) 18 | 19 | def test_flask_debugtoolbar(self): 20 | """ Test if flask debugtoolbar is loaded correctly 21 | 22 | Creating an app with default config so that debug is True 23 | and SECRET_KEY is defined. 24 | """ 25 | app = create_app(debug=True) 26 | client = app.test_client() 27 | resp = client.get("/about") 28 | self.assert200(resp) 29 | self.assertIn("flDebug", str(resp.data)) 30 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/notify_moderators.py: -------------------------------------------------------------------------------- 1 | from brainzutils.mail import send_mail 2 | from flask import current_app, render_template, url_for 3 | 4 | 5 | def mail_review_report(user, reason, review): 6 | report_email_address = current_app.config.get('ADMIN_NOTIFICATION_EMAIL_ADDRESS') 7 | if report_email_address: 8 | if not isinstance(report_email_address, list): 9 | report_email_address = [report_email_address] 10 | text = render_template( 11 | "emails/review_report.txt", 12 | username=user.display_name, 13 | review_link=url_for("review.entity", id=review["id"]), 14 | review_author=review["user"].display_name, 15 | reason=reason, 16 | ) 17 | send_mail( 18 | subject="CritiqueBrainz Spam Review Report", 19 | text=text, 20 | recipients=report_email_address, 21 | from_name="CritiqueBrainz noreply", 22 | from_addr=current_app.config['MAIL_FROM_ADDR'] 23 | ) 24 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/errors/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}{% block error_title %}{% endblock %} - CritiqueBrainz{% endblock %} 4 | 5 | {% block content %} 6 |

{{ self.error_title() }}

7 |

{% block error_description %}{{ error.description }}{% endblock %}

8 |

{% block error_info %}{{ error }}{% endblock %}

9 |

{{ _('Back to home page') }}

10 | 11 | {% if config.LOG_SENTRY is defined and config.LOG_SENTRY.dsn is defined and event_id is defined %} 12 | 17 | 18 | 24 | {% endif %} 25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/utilities.less: -------------------------------------------------------------------------------- 1 | // 2 | // Utility classes 3 | // -------------------------------------------------- 4 | 5 | 6 | // Floats 7 | // ------------------------- 8 | 9 | .clearfix { 10 | .clearfix(); 11 | } 12 | .center-block { 13 | .center-block(); 14 | } 15 | .pull-right { 16 | float: right !important; 17 | } 18 | .pull-left { 19 | float: left !important; 20 | } 21 | 22 | 23 | // Toggling content 24 | // ------------------------- 25 | 26 | // Note: Deprecated .hide in favor of .hidden or .sr-only (as appropriate) in v3.0.1 27 | .hide { 28 | display: none !important; 29 | } 30 | .show { 31 | display: block !important; 32 | } 33 | .invisible { 34 | visibility: hidden; 35 | } 36 | .text-hide { 37 | .text-hide(); 38 | } 39 | 40 | 41 | // Hide from screenreaders and browsers 42 | // 43 | // Credit: HTML5 Boilerplate 44 | 45 | .hidden { 46 | display: none !important; 47 | } 48 | 49 | 50 | // For Affix plugin 51 | // ------------------------- 52 | 53 | .affix { 54 | position: fixed; 55 | } 56 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 📜 2 | 3 | **Thank you for your interest in contributing to CritiqueBrainz!** 4 | 5 | Our primary Git repository is located at https://github.com/metabrainz/critiquebrainz. That's where all changes 6 | should be submitted. 7 | 8 | If you want to submit a bug report or suggest an improvement, please use our [bug tracker](https://tickets.metabrainz.org/browse/CB). 9 | Try to provide a good description for the ticket. If it's a bug report, tell us how the issue can be reproduced. 10 | 11 | **All MetaBrainz projects have a set of general contribution guidelines: https://github.com/metabrainz/guidelines.** Make sure 12 | to take a look at the documents in that repository, especially ones directly related to CritiqueBrainz (Python, SQL, etc.). 13 | 14 | One of the best ways to discuss something with us is by connecting to the [#metabrainz](https://kiwiirc.com/nextclient/irc.libera.chat/#musicbrainz,#metabrainz) 15 | IRC channel on the Libera.Chat network. Other ways to communicate with us are described at https://metabrainz.org/contact. 16 | -------------------------------------------------------------------------------- /admin/sql/create_primary_keys.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | ALTER TABLE comment ADD CONSTRAINT comment_pkey PRIMARY KEY (id); 3 | ALTER TABLE comment_revision ADD CONSTRAINT comment_revision_pkey PRIMARY KEY (id); 4 | ALTER TABLE license ADD CONSTRAINT license_pkey PRIMARY KEY (id); 5 | ALTER TABLE moderation_log ADD CONSTRAINT moderation_log_pkey PRIMARY KEY (id); 6 | ALTER TABLE oauth_client ADD CONSTRAINT oauth_client_pkey PRIMARY KEY (client_id); 7 | ALTER TABLE oauth_grant ADD CONSTRAINT oauth_grant_pkey PRIMARY KEY (id); 8 | ALTER TABLE oauth_token ADD CONSTRAINT oauth_token_pkey PRIMARY KEY (id); 9 | ALTER TABLE review ADD CONSTRAINT review_pkey PRIMARY KEY (id); 10 | ALTER TABLE revision ADD CONSTRAINT revision_pkey PRIMARY KEY (id); 11 | ALTER TABLE avg_rating ADD CONSTRAINT avg_rating_pkey PRIMARY KEY (entity_id, entity_type); 12 | ALTER TABLE spam_report ADD CONSTRAINT spam_report_pkey PRIMARY KEY (user_id, revision_id); 13 | ALTER TABlE "user" ADD CONSTRAINT user_pkey PRIMARY KEY (id); 14 | ALTER TABlE vote ADD CONSTRAINT vote_pkey PRIMARY KEY (user_id, revision_id); 15 | COMMIT; 16 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/entity/bb_author.html: -------------------------------------------------------------------------------- 1 | {% extends 'review/entity/base.html' %} 2 | 3 | {% set bb_author = entity %} 4 | 5 | {% block title %} 6 | {% set bb_author_name = bb_author.name %} 7 | {{ _('Review of "%(bb_author)s" by %(user)s', bb_author=bb_author, user=review.user.display_name) }} - CritiqueBrainz 8 | {% endblock %} 9 | 10 | {% block entity_title %} 11 |

12 | {% set bb_author_name = '' | safe % url_for('bb_author.entity', id=review.entity_id) ~ bb_author.name ~ ''|safe %} 13 | {{ _('%(bb_author)s', bb_author=bb_author_name) }} 14 | {% if bb_author.disambiguation is defined and bb_author.disambiguation %} 15 | {{ bb_author.disambiguation }} 16 | {% endif %} 17 |

18 | {% endblock %} 19 | 20 | {% block show_entity_type %} 21 |

22 | 23 | {{ _('BookBrainz Author') }} {{ _('View on BookBrainz') }} 24 |

25 | {% endblock %} -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/entity/bb_series.html: -------------------------------------------------------------------------------- 1 | {% extends 'review/entity/base.html' %} 2 | 3 | {% set bb_series = entity %} 4 | 5 | {% block title %} 6 | {% set series_name = bb_series.name %} 7 | {{ _('Review of "%(bb_series)s" by %(user)s', bb_series=series_name, user=review.user.display_name) }} - CritiqueBrainz 8 | {% endblock %} 9 | 10 | {% block entity_title %} 11 |

12 | {% set series_name = '' | safe % url_for('bb_series.entity', id=review.entity_id) ~ bb_series.name ~ ''|safe %} 13 | {{ _('%(bb_series)s', bb_series=series_name) }} 14 | {% if bb_series.disambiguation is defined and bb_series.disambiguation %} 15 | {{ bb_series.disambiguation }} 16 | {% endif %} 17 |

18 | {% endblock %} 19 | 20 | {% block show_entity_type %} 21 |

22 | 23 | {{ _('BookBrainz Series') }} {{ _('View on BookBrainz') }} 24 |

25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | brainzutils@git+https://github.com/metabrainz/brainzutils-python.git@v2.8.0 2 | beautifulsoup4==4.8.0 3 | click==8.1.3 4 | Flask-Babel==4.0.0 5 | Flask-Login==0.6.3 6 | Flask-SQLAlchemy==2.5.1 7 | Flask-WTF==1.2.1 8 | Markdown==3.3.6 9 | bleach==5.0.1 10 | musicbrainzngs==0.7.1 11 | pytest==7.1.1 12 | pytest-cov==3.0.0 13 | pylint==2.13.5 14 | flake8==4.0.1 15 | psycopg2-binary==2.9.3 16 | pycountry==1.20 17 | python-dateutil==2.6.1 18 | rauth==0.7.3 19 | transifex-client==0.12.4 20 | WTForms==3.0.1 21 | email-validator==1.1.3 22 | langdetect==1.0.7 23 | Flask==3.0.0 24 | Jinja2==3.1.6 25 | werkzeug==3.0.6 26 | Flask-DebugToolbar@git+https://github.com/amCap1712/flask-debugtoolbar.git@f42bb238cd3fbc79c51b93c341164c2be820025e 27 | Flask-UUID==0.2 28 | sentry-sdk[flask]==1.45.1 29 | redis==4.4.4 30 | msgpack==0.5.6 31 | requests==2.32.4 32 | SQLAlchemy==1.4.41 33 | mbdata@git+https://github.com/acoustid/mbdata.git@v29.0.0 34 | sqlalchemy-dst==1.0.1 35 | markupsafe==2.1.3 36 | itsdangerous==2.1.2 37 | flask-shell-ipython 38 | requests-mock==1.9.3 39 | orjson==3.9.15 40 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/images/placeholder_place.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 13 | 14 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/modify/work.html: -------------------------------------------------------------------------------- 1 | {% from 'macros.html' import show_life_span with context %} 2 | 3 |
4 |
5 |
{{ _('Work') }}
6 |
7 | {{ entity['name'] }} 8 | 9 | {{ show_life_span(entity, False) }} 10 | 11 |
12 |
{{ _('Type') }}
13 |
{{ entity['type'] or '-' }}
14 |
{{ _('Artists') }}
15 |
16 | {% if entity['artist-rels'] is defined and entity['artist-rels'] %} 17 | {{ entity['artist-rels'][0]['artist']['name'] or '-' }} 18 | {% set count = entity['artist-rels'] | length %} 19 | {% if count > 1 %} 20 | + {{ count - 1 }} {{ _("more") }} 21 | {% endif %} 22 | {% else %} 23 | - 24 | {% endif %} 25 |
26 | {% block more_info %} 27 | {# Information like creation date, votes etc. #} 28 | {% endblock %} 29 |
30 |
31 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/error_handlers.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-variable 2 | from flask import render_template 3 | from sentry_sdk import last_event_id 4 | 5 | 6 | def init_error_handlers(app): 7 | @app.errorhandler(400) 8 | def bad_request(error): 9 | return render_template('errors/400.html', error=error), 400 10 | 11 | @app.errorhandler(401) 12 | def unauthorized(error): 13 | return render_template('errors/401.html', error=error), 401 14 | 15 | @app.errorhandler(403) 16 | def forbidden(error): 17 | return render_template('errors/403.html', error=error), 403 18 | 19 | @app.errorhandler(404) 20 | def not_found(error): 21 | return render_template('errors/404.html', error=error), 404 22 | 23 | @app.errorhandler(500) 24 | def internal_server_error(error): 25 | return render_template('errors/500.html', error=error, event_id=last_event_id()), 500 26 | 27 | @app.errorhandler(503) 28 | def service_unavailable(error): 29 | return render_template('errors/503.html', error=error, event_id=last_event_id()), 503 30 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/media.less: -------------------------------------------------------------------------------- 1 | .media { 2 | // Proper spacing between instances of .media 3 | margin-top: 15px; 4 | 5 | &:first-child { 6 | margin-top: 0; 7 | } 8 | } 9 | 10 | .media, 11 | .media-body { 12 | zoom: 1; 13 | overflow: hidden; 14 | } 15 | 16 | .media-body { 17 | width: 10000px; 18 | } 19 | 20 | .media-object { 21 | display: block; 22 | } 23 | 24 | .media-right, 25 | .media > .pull-right { 26 | padding-left: 10px; 27 | } 28 | 29 | .media-left, 30 | .media > .pull-left { 31 | padding-right: 10px; 32 | } 33 | 34 | .media-left, 35 | .media-right, 36 | .media-body { 37 | display: table-cell; 38 | vertical-align: top; 39 | } 40 | 41 | .media-middle { 42 | vertical-align: middle; 43 | } 44 | 45 | .media-bottom { 46 | vertical-align: bottom; 47 | } 48 | 49 | // Reset margins on headings for tighter default spacing 50 | .media-heading { 51 | margin-top: 0; 52 | margin-bottom: 5px; 53 | } 54 | 55 | // Media list variation 56 | // 57 | // Undo default ul/ol styles 58 | .media-list { 59 | padding-left: 0; 60 | list-style: none; 61 | } 62 | -------------------------------------------------------------------------------- /custom_config.py.example: -------------------------------------------------------------------------------- 1 | # CUSTOM CONFIGURATION FILE 2 | # Optional variables are commented out. 3 | 4 | DEBUG = True # set to False in production mode 5 | 6 | SECRET_KEY = "CHANGE_THIS" 7 | 8 | 9 | # LOGGING 10 | 11 | #LOG_FILE = { 12 | # "filename": "./logs/log.txt", 13 | # "max_bytes": 512 * 1024, # optional 14 | # "backup_count": 100, # optional 15 | #} 16 | 17 | #LOG_SENTRY = { 18 | # "dsn": "YOUR_SENTRY_DSN", 19 | # "level": "WARNING", # optional 20 | #} 21 | 22 | 23 | # EXTERNAL SERVICES 24 | 25 | # MusicBrainz 26 | #MUSICBRAINZ_HOSTNAME = "localhost:5000" 27 | #MUSICBRAINZ_USERAGENT = "CritiqueBrainz Custom" 28 | MUSICBRAINZ_CLIENT_ID = "" 29 | MUSICBRAINZ_CLIENT_SECRET = "" 30 | MUSICBRAINZ_OAUTH_URL = "https://musicbrainz.org/new-oauth2" 31 | 32 | # OTHER STUFF 33 | 34 | # List of administrators (MusicBrainz usernames as strings) 35 | ADMINS = [] 36 | 37 | # Mail server 38 | #MAIL_SERVER = 'localhost' 39 | #MAIL_PORT = 25 40 | #MAIL_USERNAME = None 41 | #MAIL_PASSWORD = None 42 | #MAIL_FROM_ADDR = "no-reply@critiquebrainz.org" 43 | 44 | #DEBUG_TB_TEMPLATE_EDITOR_ENABLED = True 45 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/entity/bb_edition_group.html: -------------------------------------------------------------------------------- 1 | {% extends 'review/entity/base.html' %} 2 | 3 | {% set bb_edition_group = entity %} 4 | 5 | {% block title %} 6 | {{ _('Review of "%(edition_group)s" by %(user)s', edition_group=bb_edition_group.name, user=review.user.display_name) }} - CritiqueBrainz 7 | {% endblock %} 8 | 9 | {% block entity_title %} 10 |

11 | {% set edition_group_name = '' | safe % url_for('bb_edition_group.entity', id=review.entity_id) ~ bb_edition_group.name ~ ''|safe %} 12 | {{ _('%(edition_group)s', edition_group=edition_group_name) }} 13 | {% if bb_edition_group.disambiguation is defined and bb_edition_group.disambiguation %} 14 | {{ bb_edition_group.disambiguation }} 15 | {% endif %} 16 |

17 | {% endblock %} 18 | 19 | {% block show_entity_type %} 20 |

21 | 22 | {{ _('BookBrainz Edition Group') }} {{ _('View on BookBrainz') }} 23 |

24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /admin/schema_changes/11.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | -- allow users to comment on reviews 3 | CREATE TABLE comment ( 4 | id UUID NOT NULL DEFAULT uuid_generate_v4(), 5 | review_id UUID NOT NULL, 6 | user_id UUID NOT NULL, 7 | edits INTEGER NOT NULL DEFAULT 0, 8 | is_draft BOOLEAN NOT NULL DEFAULT False, 9 | is_hidden BOOLEAN NOT NULL DEFAULT False 10 | ); 11 | 12 | CREATE TABLE comment_revision ( 13 | id SERIAL NOT NULL, 14 | comment_id UUID NOT NULL, 15 | "timestamp" TIMESTAMP NOT NULL DEFAULT NOW(), 16 | text VARCHAR 17 | ); 18 | 19 | ALTER TABLE comment ADD CONSTRAINT comment_pkey PRIMARY KEY (id); 20 | ALTER TABLE comment_revision ADD CONSTRAINT comment_revision_pkey PRIMARY KEY (id); 21 | 22 | ALTER TABLE comment 23 | ADD CONSTRAINT comment_review_fkey 24 | FOREIGN KEY (review_id) 25 | REFERENCES review(id) 26 | ON DELETE CASCADE; 27 | 28 | ALTER TABLE comment_revision 29 | ADD CONSTRAINT comment_revision_comment_fkey 30 | FOREIGN KEY (comment_id) 31 | REFERENCES comment(id) 32 | ON DELETE CASCADE; 33 | 34 | COMMIT; 35 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% set entity = review.entity_id | entity_details(entity_type=review.entity_type) %} 4 | {% if entity.title is defined %} 5 | {% set entity_title = entity.title %} 6 | {% elif entity.name is defined %} 7 | {% set entity_title = entity.name %} 8 | {% endif %} 9 | 10 | {% block title %} 11 | {{ _('Delete review of "%(entity)s"', entity=entity_title) }} - CritiqueBrainz 12 | {% endblock %} 13 | 14 | {% block content %} 15 |

{{ _('Deleting review') }}

16 |
17 |
18 |
19 |

{{ _('Are you sure you want to delete your review of "%(entity)s"?', entity=entity_title) }}

20 |

{{ _('There is no way to undo this action!') }}

21 |
22 |
23 | 24 | {{ _('Cancel') }} 25 |
26 |
27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/images/placeholder_series.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/pager.less: -------------------------------------------------------------------------------- 1 | // 2 | // Pager pagination 3 | // -------------------------------------------------- 4 | 5 | 6 | .pager { 7 | padding-left: 0; 8 | margin: @line-height-computed 0; 9 | list-style: none; 10 | text-align: center; 11 | &:extend(.clearfix all); 12 | li { 13 | display: inline; 14 | > a, 15 | > span { 16 | display: inline-block; 17 | padding: 5px 14px; 18 | background-color: @pager-bg; 19 | border: 1px solid @pager-border; 20 | border-radius: @pager-border-radius; 21 | } 22 | 23 | > a:hover, 24 | > a:focus { 25 | text-decoration: none; 26 | background-color: @pager-hover-bg; 27 | } 28 | } 29 | 30 | .next { 31 | > a, 32 | > span { 33 | float: right; 34 | } 35 | } 36 | 37 | .previous { 38 | > a, 39 | > span { 40 | float: left; 41 | } 42 | } 43 | 44 | .disabled { 45 | > a, 46 | > a:hover, 47 | > a:focus, 48 | > span { 49 | color: @pager-disabled-color; 50 | background-color: @pager-bg; 51 | cursor: @cursor-disabled; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/entity/work.html: -------------------------------------------------------------------------------- 1 | {% extends 'review/entity/base.html' %} 2 | {% from 'macros.html' import show_life_span with context %} 3 | 4 | {% set work = entity %} 5 | 6 | {% block title %} 7 | {% set work_title = work.name | default(_('[Unknown work]')) %} 8 | {{ _('Review of "%(work)s" by %(user)s', work=work_title, user=review.user.display_name) }} - CritiqueBrainz 9 | {% endblock %} 10 | 11 | {% block entity_title %} 12 |

13 | {% if work %} 14 | {% set work_name = '' | safe % url_for('work.entity', id=review.entity_id) ~ work.name ~ ''|safe %} 15 | {% else %} 16 | {% set work_name = _('[Unknown work]') %} 17 | {% endif %} 18 | 19 | {{ _('%(work)s', work=work_name) }} 20 | 21 | 22 | {{ show_life_span(work, False) }} 23 | 24 |

25 | {% endblock %} 26 | 27 | {% block show_entity_type %} 28 |

29 | 30 | {{ _('MusicBrainz Work') }} {{ _('View on MusicBrainz') }} 31 |

32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/entity/bb_literary_work.html: -------------------------------------------------------------------------------- 1 | {% extends 'review/entity/base.html' %} 2 | 3 | {% set bb_literary_work = entity %} 4 | 5 | {% block title %} 6 | {% set literary_work_name = bb_literary_work.name %} 7 | {{ _('Review of "%(literary_work)s" by %(user)s', literary_work=literary_work_name, user=review.user.display_name) }} - CritiqueBrainz 8 | {% endblock %} 9 | 10 | {% block entity_title %} 11 |

12 | {% set literary_work_name = '' | safe % url_for('bb_literary_work.entity', id=review.entity_id) ~ bb_literary_work.name ~ ''|safe %} 13 | {{ _('%(bb_literary_work)s', bb_literary_work=literary_work_name) }} 14 | {% if bb_literary_work.disambiguation is defined and bb_literary_work.disambiguation %} 15 | {{ bb_literary_work.disambiguation }} 16 | {% endif %} 17 |

18 | {% endblock %} 19 | 20 | {% block show_entity_type %} 21 |

22 | 23 | {{ _('BookBrainz Literary Work') }} {{ _('View on BookBrainz') }} 24 |

25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/test_place.py: -------------------------------------------------------------------------------- 1 | from critiquebrainz.frontend.testing import FrontendTestCase 2 | 3 | class PlaceViewsTestCase(FrontendTestCase): 4 | 5 | def test_place_page(self): 6 | response = self.client.get('/place/853b36f9-8806-459c-9480-0766b8f9354b') 7 | self.assert200(response) 8 | self.assertIn('Xfinity Center', str(response.data)) 9 | 10 | response = self.client.get('/place/61648164-abca-4679-9ccb-1cf350efb349') 11 | self.assert404(response) 12 | 13 | # Concerts tab 14 | response = self.client.get('/place/853b36f9-8806-459c-9480-0766b8f9354b?event_type=concert') 15 | self.assert200(response) 16 | self.assertIn('Chicago at Xfinity Center', str(response.data)) 17 | 18 | # Festivals tab 19 | response = self.client.get('/place/853b36f9-8806-459c-9480-0766b8f9354b?event_type=festival') 20 | self.assert200(response) 21 | self.assertIn('Ozzfest 1997 in Mansfield', str(response.data)) 22 | 23 | # Other events tab 24 | response = self.client.get('/place/853b36f9-8806-459c-9480-0766b8f9354b?event_type=other') 25 | self.assert200(response) 26 | -------------------------------------------------------------------------------- /critiquebrainz/ws/errors.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-variable,unused-argument 2 | from flask import jsonify 3 | 4 | from critiquebrainz.ws import exceptions as ws_exceptions 5 | 6 | 7 | def init_error_handlers(app): 8 | @app.errorhandler(ws_exceptions.WebServiceError) 9 | def base_error_handler(error): 10 | return jsonify(error=error.code, description=error.desc), error.status 11 | 12 | @app.errorhandler(ws_exceptions.ParserError) 13 | def parser_error_handler(error): 14 | return base_error_handler(ws_exceptions.InvalidRequest('Parameter `%s`: %s' % (error.key, error.desc))) 15 | 16 | @app.errorhandler(401) 17 | def not_authorized_handler(error): 18 | return base_error_handler(ws_exceptions.NotAuthorized()) 19 | 20 | @app.errorhandler(403) 21 | def access_denied_handler(error): 22 | return base_error_handler(ws_exceptions.AccessDenied()) 23 | 24 | @app.errorhandler(404) 25 | def not_found_handler(error): 26 | return base_error_handler(ws_exceptions.NotFound()) 27 | 28 | @app.errorhandler(500) 29 | def exception_handler(error): 30 | return base_error_handler(ws_exceptions.ServerError()) 31 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/entity/label.html: -------------------------------------------------------------------------------- 1 | {% extends 'review/entity/base.html' %} 2 | {% from 'macros.html' import show_life_span with context %} 3 | 4 | {% set label = entity %} 5 | 6 | {% block title %} 7 | {% set label_title = label.name | default(_('[Unknown label]')) %} 8 | {{ _('Review of "%(label)s" by %(user)s', label=label_title, user=review.user.display_name) }} - CritiqueBrainz 9 | {% endblock %} 10 | 11 | {% block entity_title %} 12 |

13 | {% if label %} 14 | {% set label_name = '' | safe % url_for('label.entity', id=review.entity_id) ~ label.name ~ ''|safe %} 15 | {% else %} 16 | {% set label_name = _('[Unknown label]') %} 17 | {% endif %} 18 | 19 | {{ _('%(label)s', label=label_name) }} 20 | 21 | 22 | {{ show_life_span(label, False) }} 23 | 24 |

25 | {% endblock %} 26 | 27 | {% block show_entity_type %} 28 |

29 | 30 | {{ _('MusicBrainz Label') }} {{ _('View on MusicBrainz') }} 31 |

32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /critiquebrainz/data/user_types.py: -------------------------------------------------------------------------------- 1 | class UserType: 2 | 3 | def __init__(self, label, karma, reviews_per_day, votes_per_day): 4 | self.label = label 5 | self.karma = karma 6 | self.reviews_per_day = reviews_per_day 7 | self.votes_per_day = votes_per_day 8 | 9 | def is_instance(self, user): 10 | return self.karma(user.karma) 11 | 12 | 13 | blocked = UserType( 14 | label='Blocked', 15 | karma=lambda x: (x < -20), 16 | reviews_per_day=0, 17 | votes_per_day=0) 18 | 19 | spammer = UserType( 20 | label='Spammer', 21 | karma=lambda x: (-20 <= x < -10), 22 | reviews_per_day=1, 23 | votes_per_day=0) 24 | 25 | noob = UserType( 26 | label='Noob', 27 | karma=lambda x: (-10 <= x < 50), 28 | reviews_per_day=5, 29 | votes_per_day=10) 30 | 31 | apprentice = UserType( 32 | label='Apprentice', 33 | karma=lambda x: (50 <= x < 1000), 34 | reviews_per_day=20, 35 | votes_per_day=50) 36 | 37 | sorcerer = UserType( 38 | label='Sorcerer', 39 | karma=lambda x: (x >= 1000), 40 | reviews_per_day=50, 41 | votes_per_day=200) 42 | 43 | user_types = (blocked, spammer, noob, apprentice, sorcerer) 44 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/sharing.html: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/entity/artist.html: -------------------------------------------------------------------------------- 1 | {% extends 'review/entity/base.html' %} 2 | {% from 'macros.html' import show_life_span with context %} 3 | 4 | {% set artist = entity %} 5 | 6 | {% block title %} 7 | {% set artist_name = artist.name | default(_('[Unknown artist]')) %} 8 | {{ _('Review of "%(artist)s" by %(user)s', artist=artist_name, user=review.user.display_name) }} - CritiqueBrainz 9 | {% endblock %} 10 | 11 | {% block entity_title %} 12 |

13 | {% if artist %} 14 | {% set artist_name = '' | safe % url_for('artist.entity', id=review.entity_id) ~ artist.name ~ ''|safe %} 15 | {% else %} 16 | {% set artist_name = _('[Unknown artist]') %} 17 | {% endif %} 18 | 19 | {{ _('%(artist)s', artist=artist_name) }} 20 | 21 | 22 | {{ show_life_span(artist, False) }} 23 | 24 |

25 | {% endblock %} 26 | 27 | {% block show_entity_type %} 28 |

29 | 30 | {{ _('MusicBrainz Artist') }} {{ _('View on MusicBrainz') }} 31 |

32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/markdown.py: -------------------------------------------------------------------------------- 1 | from distutils.command.install_egg_info import safe_name 2 | import bleach 3 | from markdown import markdown 4 | 5 | 6 | def bleach_cb_nofollow(attrs, new=False): 7 | """A callback to bleach's Linker which adds rel="nofollow noopener" to all 8 | links found in some text. 9 | Based on `bleach.callbacks.nofollow` 10 | """ 11 | href_key = (None, "href") 12 | 13 | if href_key not in attrs: 14 | return attrs 15 | 16 | if attrs[href_key].startswith("mailto:"): 17 | return attrs 18 | 19 | rel_key = (None, "rel") 20 | rel_values = [val for val in attrs.get(rel_key, "").split(" ") if val] 21 | if "nofollow" not in [rel_val.lower() for rel_val in rel_values]: 22 | rel_values.append("nofollow") 23 | if "noopener" not in [rel_val.lower() for rel_val in rel_values]: 24 | rel_values.append("noopener") 25 | attrs[rel_key] = " ".join(rel_values) 26 | 27 | return attrs 28 | 29 | 30 | def format_markdown_as_safe_html(md_text): 31 | linker = bleach.linkifier.Linker(callbacks=[bleach_cb_nofollow]) 32 | html = markdown(md_text) 33 | safe_html = linker.linkify(html) 34 | return safe_html 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "critiquebrainz", 3 | "description": "package.json for keeping track of nodejs dependencies for critiquebrainz", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/metabrainz/critiquebrainz.git" 7 | }, 8 | "scripts": { 9 | "build": "webpack --mode production", 10 | "dev": "webpack --watch --mode development", 11 | "pre-dev": "webpack --mode development" 12 | }, 13 | "dependencies": { 14 | "bootstrap": "^3.4.1", 15 | "bootstrap-rating-input": "javiertoledo/bootstrap-rating-input#0a4ebb7d", 16 | "easymde": "^2.18.0", 17 | "jquery": "3.5.1", 18 | "leaflet": "1.1.0", 19 | "path": "0.12.7", 20 | "popper.js": "^1.14.1" 21 | }, 22 | "private": true, 23 | "devDependencies": { 24 | "@babel/core": "^7.23.2", 25 | "@babel/preset-env": "^7.23.2", 26 | "babel-loader": "^9.1.3", 27 | "css-loader": "^6.8.1", 28 | "json5": "^2.2.3", 29 | "less": "^4.2.0", 30 | "less-loader": "^11.1.3", 31 | "mini-css-extract-plugin": "^2.7.6", 32 | "style-loader": "^3.3.3", 33 | "webpack": "^5.94.0", 34 | "webpack-cli": "^5.1.4", 35 | "webpack-manifest-plugin": "^5.0.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/bookbrainz_db/test/bb_redirects_test.py: -------------------------------------------------------------------------------- 1 | from critiquebrainz.frontend.external.bookbrainz_db import redirects 2 | from critiquebrainz.data.testing import DataTestCase 3 | 4 | 5 | class BBRedirectsTestCase(DataTestCase): 6 | 7 | def setUp(self): 8 | super(BBRedirectsTestCase, self).setUp() 9 | self.bbid1 = '63a40e3d-54ff-4549-9637-24959ad89241' 10 | self.bbid2 = 'dd40b465-931f-46ee-b2ae-28685b19f8d8' 11 | self.bbid3 = 'e5c4e68b-bfce-4c77-9ca2-0f0a2d4d09f0' 12 | 13 | def test_single_bb_redirects(self): 14 | # Test single redirect 15 | redirected_bbid = redirects.get_redirected_bbid(self.bbid1) 16 | self.assertEqual(redirected_bbid, 'ecdeb45d-c432-4347-94c3-f01acc799d4a') 17 | 18 | def test_multiple_bb_redirects(self): 19 | # Test multiple redirects 20 | redirected_bbid = redirects.get_redirected_bbid(self.bbid2) 21 | self.assertEqual(redirected_bbid, '7e691222-6f78-4ad6-ad5d-1a671a319fbd') 22 | 23 | def test_no_bb_redirects(self): 24 | # Test no redirects 25 | redirected_bbid = redirects.get_redirected_bbid(self.bbid3) 26 | self.assertEqual(redirected_bbid, None) 27 | -------------------------------------------------------------------------------- /docs/api/endpoints.rst: -------------------------------------------------------------------------------- 1 | Endpoint reference 2 | ================== 3 | 4 | CritiqueBrainz provides various endpoints that can be used to interact with the 5 | data. Web API uses JSON format. 6 | 7 | **Root URL**: ``https://critiquebrainz.org/ws/1`` 8 | 9 | Below you will find description of all available endpoints. 10 | 11 | Reviews 12 | ^^^^^^^ 13 | 14 | .. autoflask:: critiquebrainz.ws:create_app_sphinx() 15 | :blueprints: ws_review 16 | :include-empty-docstring: 17 | :undoc-static: 18 | 19 | .. autoflask:: critiquebrainz.ws:create_app_sphinx() 20 | :blueprints: ws_review_bulk 21 | :include-empty-docstring: 22 | :undoc-static: 23 | 24 | Users 25 | ^^^^^ 26 | 27 | .. autoflask:: critiquebrainz.ws:create_app_sphinx() 28 | :blueprints: ws_user 29 | :include-empty-docstring: 30 | :undoc-static: 31 | 32 | OAuth 33 | ^^^^^ 34 | 35 | See :doc:`OAuth documentation ` for more info. 36 | 37 | .. autoflask:: critiquebrainz.ws:create_app_sphinx() 38 | :blueprints: ws_oauth 39 | :include-empty-docstring: 40 | :undoc-static: 41 | 42 | Constants 43 | ^^^^^^^^^ 44 | 45 | Constants that are relevant to using the API: 46 | 47 | .. autodata:: critiquebrainz.db.review.ENTITY_TYPES 48 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/moderators.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template 2 | from flask import current_app 3 | 4 | from critiquebrainz.db import users as db_users 5 | 6 | moderators_bp = Blueprint('moderators', __name__) 7 | 8 | 9 | @moderators_bp.route('/') 10 | def mods_list(): 11 | mod_usernames = set(map(str.lower, current_app.config['ADMINS'])) # MusicBrainz usernames 12 | mods_data = db_users.get_many_by_mb_username(list(mod_usernames)) 13 | mods = [] 14 | for mod_data in mods_data: 15 | # Removing from `mod_usernames` to figure out which mods don't have a CB account afterwards 16 | if mod_data["musicbrainz_username"].lower() in mod_usernames: 17 | mod_usernames.remove(mod_data["musicbrainz_username"].lower()) 18 | mods.append({ 19 | 'critiquebrainz_id': mod_data["id"], 20 | 'musicbrainz_username': mod_data["musicbrainz_username"], 21 | }) 22 | for mod_username in mod_usernames: # The rest 23 | mods.append({ 24 | 'musicbrainz_username': mod_username, 25 | }) 26 | mods = sorted(mods, key=lambda k: k['musicbrainz_username'].lower()) 27 | return render_template('moderators/moderators.html', moderators=mods) 28 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/image.less: -------------------------------------------------------------------------------- 1 | // Image Mixins 2 | // - Responsive image 3 | // - Retina image 4 | 5 | 6 | // Responsive image 7 | // 8 | // Keep images from scaling beyond the width of their parents. 9 | .img-responsive(@display: block) { 10 | display: @display; 11 | max-width: 100%; // Part 1: Set a maximum relative to the parent 12 | height: auto; // Part 2: Scale the height according to the width, otherwise you get stretching 13 | } 14 | 15 | 16 | // Retina image 17 | // 18 | // Short retina mixin for setting background-image and -size. Note that the 19 | // spelling of `min--moz-device-pixel-ratio` is intentional. 20 | .img-retina(@file-1x; @file-2x; @width-1x; @height-1x) { 21 | background-image: url("@{file-1x}"); 22 | 23 | @media 24 | only screen and (-webkit-min-device-pixel-ratio: 2), 25 | only screen and ( min--moz-device-pixel-ratio: 2), 26 | only screen and ( -o-min-device-pixel-ratio: 2/1), 27 | only screen and ( min-device-pixel-ratio: 2), 28 | only screen and ( min-resolution: 192dpi), 29 | only screen and ( min-resolution: 2dppx) { 30 | background-image: url("@{file-2x}"); 31 | background-size: @width-1x @height-1x; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/jumbotron.less: -------------------------------------------------------------------------------- 1 | // 2 | // Jumbotron 3 | // -------------------------------------------------- 4 | 5 | 6 | .jumbotron { 7 | padding: @jumbotron-padding (@jumbotron-padding / 2); 8 | margin-bottom: @jumbotron-padding; 9 | color: @jumbotron-color; 10 | background-color: @jumbotron-bg; 11 | 12 | h1, 13 | .h1 { 14 | color: @jumbotron-heading-color; 15 | } 16 | 17 | p { 18 | margin-bottom: (@jumbotron-padding / 2); 19 | font-size: @jumbotron-font-size; 20 | font-weight: 200; 21 | } 22 | 23 | > hr { 24 | border-top-color: darken(@jumbotron-bg, 10%); 25 | } 26 | 27 | .container &, 28 | .container-fluid & { 29 | border-radius: @border-radius-large; // Only round corners at higher resolutions if contained in a container 30 | } 31 | 32 | .container { 33 | max-width: 100%; 34 | } 35 | 36 | @media screen and (min-width: @screen-sm-min) { 37 | padding: (@jumbotron-padding * 1.6) 0; 38 | 39 | .container &, 40 | .container-fluid & { 41 | padding-left: (@jumbotron-padding * 2); 42 | padding-right: (@jumbotron-padding * 2); 43 | } 44 | 45 | h1, 46 | .h1 { 47 | font-size: (@font-size-base * 4.5); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /critiquebrainz/db/test/license_test.py: -------------------------------------------------------------------------------- 1 | import critiquebrainz.db.license as db_license 2 | from critiquebrainz.data.testing import DataTestCase 3 | 4 | 5 | class LicenseTestCase(DataTestCase): 6 | 7 | def test_license_create(self): 8 | license = db_license.create( 9 | id="Test", 10 | full_name="Test License", 11 | info_url="www.example.com", 12 | ) 13 | self.assertEqual(license["id"], "Test") 14 | self.assertEqual(license["full_name"], "Test License") 15 | self.assertEqual(license["info_url"], "www.example.com") 16 | 17 | @staticmethod 18 | def test_delete_license(): 19 | license = db_license.create( 20 | id="test", 21 | full_name="Test license", 22 | info_url="www.example.com", 23 | ) 24 | db_license.delete(id=license["id"]) 25 | 26 | def test_list_licenses(self): 27 | db_license.create( 28 | id="test", 29 | full_name="Test license", 30 | info_url="www.example.com", 31 | ) 32 | licenses = db_license.list_licenses() 33 | self.assertDictEqual({ 34 | "id": "test", 35 | "full_name": "Test license", 36 | "info_url": "www.example.com" 37 | }, licenses[0]) 38 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins.less: -------------------------------------------------------------------------------- 1 | // Mixins 2 | // -------------------------------------------------- 3 | 4 | // Utilities 5 | @import "mixins/hide-text.less"; 6 | @import "mixins/opacity.less"; 7 | @import "mixins/image.less"; 8 | @import "mixins/labels.less"; 9 | @import "mixins/reset-filter.less"; 10 | @import "mixins/resize.less"; 11 | @import "mixins/responsive-visibility.less"; 12 | @import "mixins/size.less"; 13 | @import "mixins/tab-focus.less"; 14 | @import "mixins/text-emphasis.less"; 15 | @import "mixins/text-overflow.less"; 16 | @import "mixins/vendor-prefixes.less"; 17 | 18 | // Components 19 | @import "mixins/alerts.less"; 20 | @import "mixins/buttons.less"; 21 | @import "mixins/panels.less"; 22 | @import "mixins/pagination.less"; 23 | @import "mixins/list-group.less"; 24 | @import "mixins/nav-divider.less"; 25 | @import "mixins/forms.less"; 26 | @import "mixins/progress-bar.less"; 27 | @import "mixins/table-row.less"; 28 | 29 | // Skins 30 | @import "mixins/background-variant.less"; 31 | @import "mixins/border-radius.less"; 32 | @import "mixins/gradients.less"; 33 | 34 | // Layout 35 | @import "mixins/clearfix.less"; 36 | @import "mixins/center-block.less"; 37 | @import "mixins/nav-vertical-align.less"; 38 | @import "mixins/grid-framework.less"; 39 | @import "mixins/grid.less"; 40 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/statistics.py: -------------------------------------------------------------------------------- 1 | # critiquebrainz - Repository for Creative Commons licensed reviews 2 | # 3 | # Copyright (C) 2019 Bimalkant Lauhny. 4 | # 5 | # This program is free software; you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation; either version 2 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License along 16 | # with this program; if not, write to the Free Software Foundation, Inc., 17 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 18 | 19 | from flask import Blueprint, render_template 20 | 21 | import critiquebrainz.db.statistics as db_statistics 22 | 23 | statistics_bp = Blueprint('statistics', __name__) 24 | 25 | 26 | @statistics_bp.route('/') 27 | def statistics(): 28 | top_users_overall = db_statistics.get_top_users_overall() 29 | return render_template( 30 | 'statistics/stats.html', 31 | top_users_overall=top_users_overall, 32 | ) 33 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/mixins/buttons.less: -------------------------------------------------------------------------------- 1 | // Button variants 2 | // 3 | // Easily pump out default styles, as well as :hover, :focus, :active, 4 | // and disabled options for all buttons 5 | 6 | .button-variant(@color; @background; @border) { 7 | color: @color; 8 | background-color: @background; 9 | border-color: @border; 10 | 11 | &:hover, 12 | &:focus, 13 | &.focus, 14 | &:active, 15 | &.active, 16 | .open > .dropdown-toggle& { 17 | color: @color; 18 | background-color: darken(@background, 10%); 19 | border-color: darken(@border, 12%); 20 | } 21 | &:active, 22 | &.active, 23 | .open > .dropdown-toggle& { 24 | background-image: none; 25 | } 26 | &.disabled, 27 | &[disabled], 28 | fieldset[disabled] & { 29 | &, 30 | &:hover, 31 | &:focus, 32 | &.focus, 33 | &:active, 34 | &.active { 35 | background-color: @background; 36 | border-color: @border; 37 | } 38 | } 39 | 40 | .badge { 41 | color: @background; 42 | background-color: @color; 43 | } 44 | } 45 | 46 | // Button sizes 47 | .button-size(@padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) { 48 | padding: @padding-vertical @padding-horizontal; 49 | font-size: @font-size; 50 | line-height: @line-height; 51 | border-radius: @border-radius; 52 | } 53 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/log.py: -------------------------------------------------------------------------------- 1 | from itertools import groupby 2 | 3 | from flask import Blueprint, render_template, request, jsonify 4 | from flask_babel import gettext 5 | 6 | import critiquebrainz.db.moderation_log as db_moderation_log 7 | from critiquebrainz.frontend import flash 8 | from werkzeug.exceptions import BadRequest 9 | 10 | log_bp = Blueprint('log', __name__) 11 | 12 | RESULTS_LIMIT = 20 13 | 14 | 15 | @log_bp.route('/') 16 | def browse(): 17 | results, count = db_moderation_log.list_logs(limit=RESULTS_LIMIT) 18 | if not results: 19 | flash.warn(gettext("No logs to display.")) 20 | results = groupby(results, lambda log: log["timestamp"].strftime('%d %b, %G')) 21 | return render_template('log/browse.html', count=count, results=results, limit=RESULTS_LIMIT) 22 | 23 | 24 | @log_bp.route('/more') 25 | def more(): 26 | try: 27 | page = int(request.args.get('page', default=0)) 28 | except ValueError: 29 | raise BadRequest("Invalid page number!") 30 | 31 | offset = page * RESULTS_LIMIT 32 | results, count = db_moderation_log.list_logs(offset=offset, limit=RESULTS_LIMIT) 33 | results = groupby(results, lambda log: log["timestamp"].strftime('%d %b, %G')) 34 | template = render_template('log/log_results.html', results=results) 35 | return jsonify(results=template, more=(count - offset - RESULTS_LIMIT) > 0) 36 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/delete_comment.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% set entity = review.entity_id | entity_details(entity_type=review.entity_type) %} 4 | {% if entity.title is defined %} 5 | {% set entity_title = entity.title %} 6 | {% elif entity.name is defined %} 7 | {% set entity_title = entity.name %} 8 | {% endif %} 9 | 10 | {% block title %} 11 | {{ _('Edit comment on %(user)s’s review of "%(entity)s"', entity=entity_title, user=review['user'].display_name) }} - CritiqueBrainz 12 | {% endblock %} 13 | 14 | {% block content %} 15 |

{{ _('Deleting comment!') }}

16 |
17 |
18 |
19 |

{{ _('Are you sure you want to delete your comment on %(user)s’s review of "%(entity)s"?', entity=entity_title, user=review['user'].display_name) }}

20 |

You're deleting:

21 |

{{ comment.text_html|safe }}

22 |

{{ _('There is no way to undo this action!') }}

23 |
24 |
25 | 26 | {{ _('Cancel') }} 27 |
28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/bookbrainz_db/test/common_entity_test.py: -------------------------------------------------------------------------------- 1 | from critiquebrainz.frontend.external.bookbrainz_db import common_entity 2 | from critiquebrainz.data.testing import DataTestCase 3 | 4 | 5 | class BB_MBCommonEntityTestCase(DataTestCase): 6 | 7 | def setUp(self): 8 | super(BB_MBCommonEntityTestCase, self).setUp() 9 | self.bbid1 = '569c0d90-28dd-413b-83e4-aaa7c27e667b' 10 | self.bbid2 = 'a4a6a48a-42a5-493a-9fa1-aaf6a82217e2' 11 | self.bbid3 = 'a99374d5-fa8b-4fab-9fec-9c0c38e8ac7c' 12 | self.bbid4 = '0e5a48f3-7d21-365c-bfb7-98d9865ea1dd' 13 | 14 | def test_get_authors_for_artist(self): 15 | author_bbids1 = common_entity.get_authors_for_artist(self.bbid1) 16 | self.assertEqual(len(author_bbids1), 1) 17 | self.assertEqual(author_bbids1[0], 'e5c4e68b-bfce-4c77-9ca2-0f0a2d4d09f0') 18 | 19 | author_bbids2 = common_entity.get_authors_for_artist(self.bbid2) 20 | self.assertEqual(author_bbids2, []) 21 | 22 | def test_get_literary_works_for_work(self): 23 | work_bbids1 = common_entity.get_literary_works_for_work(self.bbid3) 24 | self.assertEqual(len(work_bbids1), 1) 25 | self.assertEqual(work_bbids1[0], 'f89e85c0-e341-4b0e-ada6-36655f5dae07') 26 | 27 | work_bbids2 = common_entity.get_literary_works_for_work(self.bbid4) 28 | self.assertEqual(work_bbids2, []) 29 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/theme.less: -------------------------------------------------------------------------------- 1 | @import "boostrap/boostrap.less"; 2 | @import "variables.less"; 3 | @import "links.less"; 4 | @import "buttons.less"; 5 | @import "navbars.less"; 6 | 7 | // Fonts 8 | @import url(https://fonts.googleapis.com/css?family=Roboto:100,400,300,700|Sintony:200,400,700&subset=latin,latin-ext,cyrillic,cyrillic-ext); 9 | @font-family-sans-serif: 'Sintony', sans-serif; 10 | @headings-font-family: 'Roboto', sans-serif; 11 | @headings-font-weight: 300; 12 | @headings-line-height: 1.3; 13 | 14 | // Headings 15 | h1, h2, h3, h4, h5, h6, 16 | .h1, .h2, .h3, .h4, .h5, .h6 { 17 | color: @headings-font-color; 18 | small { 19 | font-size: 0.65em; 20 | font-weight: @headings-small-font-weight; 21 | } 22 | } 23 | 24 | .panel { 25 | color: @text-color; 26 | -webkit-box-shadow: none; 27 | box-shadow: none; 28 | border-bottom-color: @panel-inner-border; 29 | .panel-heading { 30 | font-family: @headings-font-family; 31 | font-weight: 200; 32 | line-height: @headings-line-height; 33 | outline: 1px solid @panel-inner-border; 34 | border-bottom-width: 1px; 35 | } 36 | } 37 | 38 | ul.hexagonBullet li:before, .hexagonBullet:not(ul):before { 39 | content: "\2B22"; 40 | margin-right: 0.5em; 41 | font-size: 0.5em; 42 | opacity: 0.42; 43 | vertical-align: middle; 44 | } 45 | 46 | ul.hexagonBullet { 47 | list-style: none; 48 | } 49 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/compare.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}{{ _('Difference between revisions') }} - CritiqueBrainz{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |

{{ _('Revision') }} {{ left['number'] }}

10 |

{{ _('as of') }} {{ left['timestamp'] | datetime }}

11 |
12 | {% if left.rating or right.rating %} 13 | 14 | {% endif %} 15 |
{{ left['text'] | safe }}
16 |
17 |
18 |
19 |

{{ _('Revision') }} {{ right['number'] }}

20 |

{{ _('as of') }} {{ right['timestamp'] | datetime }}

21 |
22 | {% if right.rating or left.rating %} 23 | 24 | {% endif %} 25 |
{{ right['text'] | safe }}
26 |
27 |
28 | {% endblock %} 29 | 30 | {% block scripts %} 31 | {{ super() }} 32 | 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /critiquebrainz/ws/testing.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import critiquebrainz.db.oauth_client as db_oauth_client 4 | import critiquebrainz.db.users as db_users 5 | from critiquebrainz.testing import ServerTestCase 6 | from critiquebrainz.ws import create_app 7 | from critiquebrainz.ws.oauth import oauth 8 | 9 | 10 | class WebServiceTestCase(ServerTestCase): 11 | 12 | @classmethod 13 | def create_app(cls): 14 | app = create_app( 15 | config_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', '..', 'test_config.py') 16 | ) 17 | oauth.init_app(app) 18 | return app 19 | 20 | @staticmethod 21 | def create_dummy_client(user): 22 | db_oauth_client.create( 23 | user_id=user.id, 24 | name="Dummy Client", 25 | desc="Created for testing the webservice", 26 | website="http://example.com/", 27 | redirect_uri="http://example.com/redirect/", 28 | ) 29 | client = db_users.clients(user.id)[0] 30 | return client 31 | 32 | def create_dummy_token(self, user, client=None): 33 | if client is None: 34 | client = self.create_dummy_client(user) 35 | token = oauth.generate_token(client_id=client["client_id"], refresh_token="", 36 | user_id=user.id, scope="review vote user") 37 | return token[0] 38 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/forms/profile.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from flask_babel import lazy_gettext 3 | from wtforms import StringField, BooleanField, RadioField, validators 4 | from wtforms.fields import EmailField 5 | 6 | 7 | class ProfileEditForm(FlaskForm): 8 | display_name = StringField(lazy_gettext("Display name"), [ 9 | validators.InputRequired(message=lazy_gettext("Display name field is empty.")), 10 | validators.Length(min=3, message=lazy_gettext("Display name needs to be at least 3 characters long.")), 11 | validators.Length(max=64, message=lazy_gettext("Display name needs to be at most 64 characters long."))]) 12 | email = EmailField(lazy_gettext("Email"), [ 13 | validators.Optional(strip_whitespace=False), 14 | validators.Email(message=lazy_gettext("Email field is not a valid email address."))]) 15 | license_choice = RadioField(lazy_gettext("Preferred License Choice"), choices=[ 16 | ('CC BY-SA 3.0', lazy_gettext('Allow commercial use of my reviews (CC BY-SA 3.0 license)')), # noqa: E501 17 | ('CC BY-NC-SA 3.0', lazy_gettext('Do not allow commercial use of my reviews, unless approved by MetaBrainz Foundation (CC BY-NC-SA 3.0 license)')), # noqa: E501 18 | ]) 19 | -------------------------------------------------------------------------------- /.github/workflows/deploy-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish image to Docker Hub 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | 9 | deploy: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | # Github stores the current tag in an environment variable (GITHUB_REF) in the format /refs/tags/TAG_NAME. 15 | # Using shell parameter expansion, we extract the TAG_NAME. Also, it seems we cannot use shell tricks 16 | # directly in the with block, so doing it in a separate step and then fetching its output when needed. 17 | - name: Get the tag name 18 | id: get_tag 19 | run: echo ::set-output name=TAG::${GITHUB_REF/refs\/tags\//} 20 | 21 | - name: Docker Setup Buildx 22 | uses: docker/setup-buildx-action@v1.3.0 23 | 24 | - name: Docker Login 25 | uses: docker/login-action@v1.9.0 26 | with: 27 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 28 | password: ${{ secrets.DOCKER_HUB_PASSWORD }} 29 | 30 | - name: Build and push Docker images 31 | uses: docker/build-push-action@v2.4.0 32 | with: 33 | build-args: GIT_COMMIT_SHA=${{ steps.get_tag.outputs.TAG }} 34 | cache-from: metabrainz/critiquebrainz:cache 35 | cache-to: metabrainz/critiquebrainz:cache 36 | push: true 37 | tags: metabrainz/critiquebrainz:${{ steps.get_tag.outputs.TAG }} 38 | target: critiquebrainz-prod 39 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: CritiqueBrainz CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ '*' ] 8 | 9 | jobs: 10 | 11 | test: 12 | name: Run test suite 13 | runs-on: ubuntu-latest 14 | permissions: 15 | checks: write 16 | pull-requests: write 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Create configuration file 22 | run: cp custom_config.py.example custom_config.py 23 | 24 | - name: Login to Docker Hub 25 | run: echo ${{ secrets.DOCKER_HUB_PASSWORD }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin 26 | continue-on-error: true 27 | 28 | - name: Run tests 29 | run: ./test.sh 30 | 31 | - name: Publish Test Results 32 | uses: EnricoMi/publish-unit-test-result-action@v2 33 | if: ${{ always() }} 34 | with: 35 | files: reports/tests.xml 36 | 37 | prod: 38 | name: Build Production Image 39 | runs-on: ubuntu-latest 40 | needs: test 41 | 42 | steps: 43 | - uses: actions/checkout@v2 44 | 45 | - name: Login to Docker Hub 46 | run: echo ${{ secrets.DOCKER_HUB_PASSWORD }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin 47 | continue-on-error: true 48 | 49 | - name: Build production image 50 | run: docker build --build-arg GIT_COMMIT_SHA=HEAD . 51 | -------------------------------------------------------------------------------- /admin/rsync-dump-files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # critiquebrainz - Repository for Creative Commons licensed reviews 4 | # 5 | # Copyright (C) 2018 MetaBrainz Foundation Inc. 6 | # 7 | # This program is free software; you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 20 | 21 | unset SSH_AUTH_SOCK 22 | 23 | CB_SERVER_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../" && pwd) 24 | cd "$CB_SERVER_ROOT" 25 | 26 | source admin/config.sh 27 | source admin/functions.sh 28 | 29 | retry rsync \ 30 | --archive \ 31 | --delete \ 32 | -FF \ 33 | --rsh "ssh -i $RSYNC_FULLEXPORT_KEY -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -p $RSYNC_FULLEXPORT_PORT" \ 34 | --verbose \ 35 | $RSYNC_FULLEXPORT_DIR/ \ 36 | brainz@$RSYNC_FULLEXPORT_HOST:./ 37 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/labels.less: -------------------------------------------------------------------------------- 1 | // 2 | // Labels 3 | // -------------------------------------------------- 4 | 5 | .label { 6 | display: inline; 7 | padding: .2em .6em .3em; 8 | font-size: 75%; 9 | font-weight: bold; 10 | line-height: 1; 11 | color: @label-color; 12 | text-align: center; 13 | white-space: nowrap; 14 | vertical-align: baseline; 15 | border-radius: .25em; 16 | 17 | // Add hover effects, but only for links 18 | a& { 19 | &:hover, 20 | &:focus { 21 | color: @label-link-hover-color; 22 | text-decoration: none; 23 | cursor: pointer; 24 | } 25 | } 26 | 27 | // Empty labels collapse automatically (not available in IE8) 28 | &:empty { 29 | display: none; 30 | } 31 | 32 | // Quick fix for labels in buttons 33 | .btn & { 34 | position: relative; 35 | top: -1px; 36 | } 37 | } 38 | 39 | // Colors 40 | // Contextual variations (linked labels get darker on :hover) 41 | 42 | .label-default { 43 | .label-variant(@label-default-bg); 44 | } 45 | 46 | .label-primary { 47 | .label-variant(@label-primary-bg); 48 | } 49 | 50 | .label-success { 51 | .label-variant(@label-success-bg); 52 | } 53 | 54 | .label-info { 55 | .label-variant(@label-info-bg); 56 | } 57 | 58 | .label-warning { 59 | .label-variant(@label-warning-bg); 60 | } 61 | 62 | .label-danger { 63 | .label-variant(@label-danger-bg); 64 | } 65 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/buttons.less: -------------------------------------------------------------------------------- 1 | .btn { 2 | border-radius: 0; 3 | transition: all 0.2s; 4 | margin: 0.15em; 5 | .button-size(@padding-small-vertical; @padding-small-horizontal * 2; @font-size-small; @line-height-small; .25rem); 6 | } 7 | 8 | .button-variant(@color; @background:transparent; @border:@background) { 9 | color: @background; 10 | background: @background; 11 | border-color: @background; 12 | background-color: transparent; 13 | &:hover, &:focus, &:active, &.active, .open .dropdown-toggle& { 14 | background: lighten(@background, 7%); 15 | } 16 | &.disabled, &[disabled], fieldset[disabled] & { 17 | &, &:hover, &:focus, &:active, &.active { 18 | color: lighten(@color, 10%); 19 | background: desaturate(@background, 15%); 20 | } 21 | } 22 | } 23 | 24 | .btn-link:before { 25 | border-color: transparent; 26 | &:hover, &:focus, &:active { border-color: @btn-primary-border; } 27 | } 28 | 29 | .btn-lg { 30 | // line-height: ensure even-numbered height of button next to large input 31 | .button-size(@padding-large-vertical; @padding-large-horizontal * 3; @font-size-large; @line-height-large; .25rem); 32 | } 33 | 34 | .btn-sm, .btn-xs { 35 | // line-height: ensure proper height of button next to small input 36 | .button-size(@padding-small-vertical; @padding-small-horizontal * 1.5; @font-size-small; @line-height-small; .25rem); 37 | } 38 | 39 | .btn-xs { 40 | padding: 1px 5px; 41 | } 42 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/boostrap.less: -------------------------------------------------------------------------------- 1 | // Feel free to disable modules that you don't need. 2 | 3 | 4 | // Core variables and mixins 5 | @import "variables.less"; 6 | @import "mixins.less"; 7 | 8 | // Reset 9 | @import "normalize.less"; 10 | @import "print.less"; 11 | 12 | // Core CSS 13 | @import "scaffolding.less"; 14 | @import "type.less"; 15 | //@import "code.less"; 16 | @import "grid.less"; 17 | @import "tables.less"; 18 | @import "forms.less"; 19 | @import "buttons.less"; 20 | 21 | // Components 22 | @import "component-animations.less"; 23 | @import "glyphicons.less"; 24 | @import "dropdowns.less"; 25 | @import "button-groups.less"; 26 | @import "input-groups.less"; 27 | @import "navs.less"; 28 | @import "navbar.less"; 29 | //@import "breadcrumbs.less"; 30 | @import "pagination.less"; 31 | @import "pager.less"; 32 | @import "labels.less"; 33 | //@import "badges.less"; 34 | //@import "jumbotron.less"; 35 | @import "thumbnails.less"; 36 | @import "alerts.less"; 37 | //@import "progress-bars.less"; 38 | //@import "media.less"; 39 | //@import "list-group.less"; 40 | @import "panels.less"; 41 | //@import "responsive-embed.less"; 42 | @import "wells.less"; 43 | //@import "close.less"; 44 | 45 | // Components w/ JavaScript 46 | @import "modals.less"; 47 | @import "tooltip.less"; 48 | //@import "popovers.less"; 49 | //@import "carousel.less"; 50 | 51 | // Utility classes 52 | @import "utilities.less"; 53 | @import "responsive-utilities.less"; 54 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/modify/edit.html: -------------------------------------------------------------------------------- 1 | {% extends 'review/modify/base.html' %}s 2 | 3 | {% block title %} 4 | {{ _('Edit review of "%(entity)s"', entity=entity_title) }} - CritiqueBrainz 5 | {% endblock %} 6 | 7 | {% block header %} 8 |

{{ _('Editing review') }}

9 | {% endblock %} 10 | 11 | {% block more_info %} 12 |
{{ _('Created on') }}
{{ review.created | date }}
13 | {% if not review.is_draft %} 14 |
{{ _('Votes (+/-)') }}
{{ review.votes_positive_count }}/{{ review.votes_negative_count }}
15 | {% endif %} 16 |
{{ _('Status') }}
17 |
18 | {% if review.is_draft %} 19 | {{ _('Draft') }} 20 | {% else %} 21 | {{ _('Published') }} 22 | {% endif %} 23 |
24 | {% endblock %} 25 | 26 | {% block buttons %} 27 | {% if review.is_draft %} 28 | 29 | 30 | {% else %} 31 | 32 | {% endif %} 33 | {{ _('Discard changes') }} 34 | {{ _('Delete this review') }} 35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/index.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | from flask import Blueprint, render_template 3 | from flask_babel import format_number 4 | 5 | from critiquebrainz.frontend.views import markdown 6 | import critiquebrainz.db.review as db_review 7 | import critiquebrainz.db.users as db_users 8 | 9 | 10 | frontend_bp = Blueprint('frontend', __name__) 11 | 12 | 13 | @frontend_bp.route('/') 14 | def index(): 15 | # Popular reviews 16 | popular_reviews = db_review.get_popular_reviews_for_index() 17 | for review in popular_reviews: 18 | # Preparing text for preview 19 | preview = markdown.format_markdown_as_safe_html(review['text']) 20 | review['preview'] = ''.join(BeautifulSoup(preview, "html.parser").findAll(text=True)) 21 | 22 | # Recent reviews 23 | recent_reviews, _ = db_review.list_reviews(sort='published_on', limit=9, review_type='review') 24 | 25 | # Statistics 26 | review_count = format_number(db_review.get_count(is_draft=False)) 27 | user_count = format_number(db_users.total_count()) 28 | 29 | return render_template('index/index.html', popular_reviews=popular_reviews, recent_reviews=recent_reviews, 30 | reviews_total=review_count, users_total=user_count) 31 | 32 | 33 | @frontend_bp.route('/about') 34 | def about(): 35 | return render_template('index/about.html') 36 | 37 | 38 | @frontend_bp.route('/guidelines') 39 | def guidelines(): 40 | return render_template('index/guidelines.html') 41 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/profile.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template, request, redirect, url_for 2 | from flask_babel import gettext 3 | from flask_login import login_required, current_user 4 | 5 | import critiquebrainz.db.users as db_users 6 | from critiquebrainz.frontend import flash 7 | from critiquebrainz.frontend.forms.profile import ProfileEditForm 8 | 9 | profile_bp = Blueprint('profile_details', __name__) 10 | 11 | 12 | @profile_bp.route('/edit', methods=['GET', 'POST']) 13 | @login_required 14 | def edit(): 15 | form = ProfileEditForm() 16 | if form.validate_on_submit(): 17 | db_users.update(current_user.id, user_new_info={ 18 | "display_name": form.display_name.data, 19 | "email": form.email.data, 20 | "license_choice": form.license_choice.data, 21 | }) 22 | flash.success(gettext("Profile updated.")) 23 | return redirect(url_for('user.reviews', user_ref=current_user.user_ref)) 24 | 25 | form.display_name.data = current_user.display_name 26 | form.email.data = current_user.email 27 | form.license_choice.data = current_user.license_choice 28 | return render_template('profile/edit.html', form=form) 29 | 30 | 31 | @profile_bp.route('/delete', methods=['GET', 'POST']) 32 | @login_required 33 | def delete(): 34 | if request.method == 'POST': 35 | db_users.delete(current_user.id) 36 | return redirect(url_for('frontend.index')) 37 | return render_template('profile/delete.html') 38 | -------------------------------------------------------------------------------- /critiquebrainz/data/fixtures.py: -------------------------------------------------------------------------------- 1 | import critiquebrainz.db.license as db_license 2 | 3 | 4 | def install(*args): 5 | for arg in args: 6 | if arg == LicenseData: 7 | for key, entity in arg.__dict__.items(): 8 | if not key.startswith("__"): 9 | try: 10 | db_license.create( 11 | id=entity["id"], 12 | full_name=entity["full_name"], 13 | info_url=entity["info_url"], 14 | ) 15 | except Exception: 16 | print('Failed to add %s!' % key) 17 | else: 18 | print('Added %s.' % key) 19 | 20 | 21 | class LicenseData: 22 | """Licenses that can be used with reviews. 23 | 24 | If you add new ones or remove existing, make sure to update forms, 25 | views, and other stuff that depends on that. 26 | """ 27 | cc_by_sa_3 = dict( 28 | id="CC BY-SA 3.0", 29 | full_name="Creative Commons Attribution-ShareAlike 3.0 Unported", 30 | info_url="https://creativecommons.org/licenses/by-sa/3.0/", 31 | ) 32 | cc_by_nc_sa_3 = dict( 33 | id="CC BY-NC-SA 3.0", 34 | full_name="Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported", 35 | info_url="https://creativecommons.org/licenses/by-nc-sa/3.0/", 36 | ) 37 | 38 | 39 | # Include all objects into this tuple. 40 | all_data = (LicenseData,) 41 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/bookbrainz_db/test/author_test.py: -------------------------------------------------------------------------------- 1 | from critiquebrainz.frontend.external.bookbrainz_db import author 2 | from critiquebrainz.data.testing import DataTestCase 3 | 4 | class AuthorTestCase(DataTestCase): 5 | 6 | def setUp(self): 7 | 8 | super(AuthorTestCase, self).setUp() 9 | self.bbid1 = "49d873e6-7f3e-4160-9833-5b17d89cf4dc" 10 | self.bbid2 = "5df290b8-ecd5-44fb-8d05-70e291133688" 11 | self.bbid3 = "e5c4e68b-bfce-4c77-9ca2-0f0a2d4d09f0" 12 | 13 | def test_get_author_by_bbid(self): 14 | author_info = author.get_author_by_bbid(self.bbid1) 15 | self.assertEqual(author_info["bbid"], self.bbid1) 16 | self.assertEqual(author_info["name"], "William Shakespeare") 17 | self.assertEqual(author_info["sort_name"], "Shakespeare, William") 18 | self.assertEqual(author_info["author_type"], "Person") 19 | 20 | def test_fetch_multiple_authors(self): 21 | authors = author.fetch_multiple_authors([self.bbid2, self.bbid3]) 22 | self.assertEqual(len(authors), 2) 23 | self.assertEqual(authors[self.bbid2]["bbid"], self.bbid2) 24 | self.assertEqual(authors[self.bbid2]["name"], "Charles Dickens") 25 | self.assertEqual(authors[self.bbid2]["author_type"], "Person") 26 | self.assertEqual(authors[self.bbid3]["bbid"], self.bbid3) 27 | self.assertEqual(authors[self.bbid3]["name"], "J. K. Rowling") 28 | self.assertEqual(authors[self.bbid3]["author_type"], "Person") 29 | -------------------------------------------------------------------------------- /critiquebrainz/expand.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import html 3 | import re 4 | 5 | 6 | def encode_entities(string, quote=True): 7 | return html.escape(string, quote).encode('ascii', 'xmlcharrefreplace').decode('utf8') 8 | 9 | 10 | def expand(string, args, tag='a', default_attribute='href'): 11 | 12 | def make_link(match): 13 | var = match.group(1) 14 | text = match.group(2) 15 | if text in args.keys(): 16 | final_text = args[text] 17 | else: 18 | final_text = text 19 | 20 | if isinstance(args[var], dict): 21 | d = args[var] 22 | else: 23 | if default_attribute: 24 | d = {default_attribute: args[var]} 25 | else: 26 | d = {} 27 | attribs = ' '.join(["%s=\"%s\"" % (k, encode_entities(d[k])) for k 28 | in sorted(d.keys())]) 29 | if attribs: 30 | attribs = ' ' + attribs 31 | return '<%s%s>%s' % (tag, attribs, final_text, tag) 32 | 33 | def simple_expr(match): 34 | var = match.group(1) 35 | if var in args.keys(): 36 | return args[var] 37 | return '{' + var + '}' 38 | 39 | r = '|'.join([re.escape(k) for k in args.keys()]) 40 | 41 | r1 = re.compile('\{(' + r + ')\|(.*?)\}', re.UNICODE) 42 | r2 = re.compile('\{(' + r + ')\}', re.UNICODE) 43 | 44 | string = r1.sub(make_link, string) 45 | string = r2.sub(simple_expr, string) 46 | 47 | return string 48 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/bookbrainz_db/test/series_test.py: -------------------------------------------------------------------------------- 1 | from critiquebrainz.frontend.external.bookbrainz_db import series 2 | from critiquebrainz.data.testing import DataTestCase 3 | 4 | 5 | class SeriesTestCase(DataTestCase): 6 | def setUp(self): 7 | super(SeriesTestCase, self).setUp() 8 | self.bbid1 = "e6f48cbd-26de-4c2e-a24a-29892f9eb3be" 9 | self.bbid2 = "29b7d60f-0be1-428d-8a2d-71f3abb8d218" 10 | self.bbid3 = "968ef651-6a70-410f-9b17-f326ee0062c3" 11 | 12 | def test_get_series_by_bbid(self): 13 | series_info = series.get_series_by_bbid(self.bbid1) 14 | self.assertEqual(series_info["bbid"], self.bbid1) 15 | self.assertEqual(series_info["name"], "Harry Potter") 16 | self.assertEqual(series_info["sort_name"], "Harry Potter") 17 | self.assertEqual(series_info["series_type"], "Work") 18 | 19 | def test_fetch_multiple_series(self): 20 | series_info = series.fetch_multiple_series([self.bbid2, self.bbid3]) 21 | self.assertEqual(len(series_info), 2) 22 | self.assertEqual(series_info[self.bbid2]["bbid"], self.bbid2) 23 | self.assertEqual(series_info[self.bbid2]["name"], "The Lord of the Rings") 24 | self.assertEqual(series_info[self.bbid2]["series_type"], "Work") 25 | self.assertEqual(series_info[self.bbid3]["bbid"], self.bbid3) 26 | self.assertEqual(series_info[self.bbid3]["name"], "The Hunger Games") 27 | self.assertEqual(series_info[self.bbid3]["series_type"], "Work") 28 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/favicons/musicbrainz-16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | ]> 13 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/log/browse.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %} {{ _('Moderation log') }} - CritiqueBrainz {% endblock %} 4 | 5 | {% block content %} 6 |

{{ _('Moderation log') }}

7 |
{% include 'log/log_results.html' %}
8 | {% if count > limit %} 9 |
10 | 12 | 14 |
15 | {% endif %} 16 | {% endblock %} 17 | 18 | {% if count > limit %} 19 | {% block scripts %} 20 | {{ super() }} 21 | 42 | {% endblock %} 43 | {% endif %} 44 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/badges.less: -------------------------------------------------------------------------------- 1 | // 2 | // Badges 3 | // -------------------------------------------------- 4 | 5 | 6 | // Base class 7 | .badge { 8 | display: inline-block; 9 | min-width: 10px; 10 | padding: 3px 7px; 11 | font-size: @font-size-small; 12 | font-weight: @badge-font-weight; 13 | color: @badge-color; 14 | line-height: @badge-line-height; 15 | vertical-align: baseline; 16 | white-space: nowrap; 17 | text-align: center; 18 | background-color: @badge-bg; 19 | border-radius: @badge-border-radius; 20 | 21 | // Empty badges collapse automatically (not available in IE8) 22 | &:empty { 23 | display: none; 24 | } 25 | 26 | // Quick fix for badges in buttons 27 | .btn & { 28 | position: relative; 29 | top: -1px; 30 | } 31 | 32 | .btn-xs &, 33 | .btn-group-xs > .btn & { 34 | top: 0; 35 | padding: 1px 5px; 36 | } 37 | 38 | // Hover state, but only for links 39 | a& { 40 | &:hover, 41 | &:focus { 42 | color: @badge-link-hover-color; 43 | text-decoration: none; 44 | cursor: pointer; 45 | } 46 | } 47 | 48 | // Account for badges in navs 49 | .list-group-item.active > &, 50 | .nav-pills > .active > a > & { 51 | color: @badge-active-color; 52 | background-color: @badge-active-bg; 53 | } 54 | 55 | .list-group-item > & { 56 | float: right; 57 | } 58 | 59 | .list-group-item > & + & { 60 | margin-right: 5px; 61 | } 62 | 63 | .nav-pills > li > a > & { 64 | margin-left: 3px; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/login.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request, redirect, render_template, url_for, session 2 | from flask_babel import gettext 3 | from flask_login import login_user, logout_user, login_required 4 | 5 | from critiquebrainz.frontend import flash 6 | from critiquebrainz.frontend.login import mb_auth, login_forbidden 7 | 8 | login_bp = Blueprint('login', __name__) 9 | 10 | 11 | @login_bp.route('/') 12 | @login_forbidden 13 | def index(): 14 | return render_template('login/index.html') 15 | 16 | 17 | @login_bp.route('/musicbrainz') 18 | @login_forbidden 19 | def musicbrainz(): 20 | session['next'] = request.args.get('next') 21 | return redirect(mb_auth.get_authentication_uri()) 22 | 23 | 24 | @login_bp.route('/musicbrainz/post') 25 | @login_forbidden 26 | def musicbrainz_post(): 27 | """Callback endpoint.""" 28 | if mb_auth.validate_post_login(): 29 | user = mb_auth.get_user() 30 | if user: 31 | login_user(user) 32 | next = session.get('next') 33 | if next: 34 | return redirect(next) 35 | else: 36 | flash.error(gettext("Login failed.")) 37 | else: 38 | flash.error(gettext("Login failed.")) 39 | return redirect(url_for('frontend.index')) 40 | 41 | 42 | @login_bp.route('/logout') 43 | @login_required 44 | def logout(): 45 | logout_user() 46 | session.clear() 47 | next = request.args.get('next') 48 | if next: 49 | return redirect(next) 50 | return redirect(url_for('frontend.index')) 51 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/report.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% set entity = review.entity_id | entity_details(entity_type=review.entity_type) %} 4 | {% if entity.title is defined %} 5 | {% set e_title = entity.title %} 6 | {% elif entity.name is defined %} 7 | {% set e_title = entity.name %} 8 | {% else %} 9 | {% set e_title = _('Unknown entity') %} 10 | {% endif %} 11 | 12 | {% block title %}{{ _('Report %(user)s’s review of "%(title)s"', title=e_title, user=review.user.display_name) }} - CritiqueBrainz{% endblock %} 13 | 14 | {% block content %} 15 |

{{ _('Report %(user)s’s review of "%(title)s"', title=e_title, user=review.user.display_name) }}

16 |
17 | 18 | {% for field in form.errors %} 19 | {% for error in form.errors[field] %} 20 |
{{ error }}
21 | {% endfor %} 22 | {% endfor %} 23 | 24 |
25 |
26 |
27 | {{ form.hidden_tag() }} 28 |
29 |
{{ form.reason(class="form-control", required="required", placeholder=_('Please provide a description of the violation (required)')) }}
30 |
31 |
32 | 33 | {{ _('Cancel') }} 34 |
35 |
36 |
37 |
38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/base.html: -------------------------------------------------------------------------------- 1 | {% from 'macros.html' import print_message with context %} 2 | 3 | 4 | 5 | 6 | {% block head %} 7 | 8 | {% block title %}CritiqueBrainz{% endblock %} 9 | 10 | 11 | 12 | 13 | {% endblock %} 14 | 15 | {% block scripts_top %} 16 | {# This needs to be before body because it's used during cover art loading. #} 17 | 18 | {% endblock %} 19 | 20 | 21 | 22 | 23 | {% include 'navbar.html' %} 24 | 25 |
26 | 27 | {% block wrapper %} 28 | {% with messages = get_flashed_messages(with_categories=true) %} 29 | {% if messages %} 30 | {% for category, message in messages %} 31 | {{ print_message(message, category) }} 32 | {% endfor %} 33 | {% endif %} 34 | {% endwith %} 35 | {% block content %} 36 | 37 | {% endblock %} 38 | {% endblock %} 39 |
40 | 41 | {% include 'footer.html' %} 42 | 43 |
44 | 45 | {% block scripts %}{% endblock %} 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /admin/sql/test/bb-test-data.sql: -------------------------------------------------------------------------------- 1 | -- Sample Data for testing relationships between entities 2 | 3 | INSERT INTO relationship (id, type_id,source_bbid, target_bbid) 4 | VALUES (99999999, 5 | 8, 6 | 'e5c4e68b-bfce-4c77-9ca2-0f0a2d4d09f0', 7 | '9f49df73-8ee5-4c5f-8803-427c9b216d8f'); 8 | 9 | INSERT INTO relationship_set (id) VALUES (99999999); 10 | 11 | INSERT INTO relationship_set__relationship (set_id, relationship_id) 12 | VALUES (99999999, 13 | 99999999); 14 | 15 | 16 | -- Sample Data for testing author credits for edition groups 17 | 18 | UPDATE edition_group_data 19 | SET author_credit_id = NULL 20 | FROM edition_group_revision as egr, edition_group_header as egh 21 | WHERE egr.data_id = edition_group_data.id 22 | AND egh.master_revision_id = egr.id 23 | AND egh.bbid = 'fd84cf1f-b288-4ea2-8e05-41257764fa6b'; 24 | 25 | 26 | INSERT INTO author_credit (id, author_count, ref_count) 27 | VALUES (99999999, 28 | 1, 29 | 0); 30 | 31 | 32 | INSERT INTO author_credit_name (author_credit_id, position, author_bbid, name, join_phrase ) 33 | VALUES (99999999, 34 | 0, 35 | 'e5c4e68b-bfce-4c77-9ca2-0f0a2d4d09f0', 36 | 'Test Author', 37 | 'Test Join Phrase'); 38 | 39 | 40 | UPDATE edition_group_data 41 | SET author_credit_id = 99999999 42 | FROM edition_group_revision as egr, edition_group_header as egh 43 | WHERE egr.data_id = edition_group_data.id 44 | AND egh.master_revision_id = egr.id 45 | AND egh.bbid = '9f49df73-8ee5-4c5f-8803-427c9b216d8f'; 46 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/forms/rate.py: -------------------------------------------------------------------------------- 1 | # critiquebrainz - Repository for Creative Commons licensed reviews 2 | # 3 | # Copyright (C) 2018 MetaBrainz Foundation Inc. 4 | # 5 | # This program is free software; you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation; either version 2 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License along 16 | # with this program; if not, write to the Free Software Foundation, Inc., 17 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 18 | 19 | from flask_wtf import FlaskForm 20 | from flask_babel import lazy_gettext 21 | from wtforms import validators, IntegerField, StringField 22 | from wtforms.widgets import Input, HiddenInput 23 | 24 | 25 | class RatingEditForm(FlaskForm): 26 | rating = IntegerField(lazy_gettext("Rating"), widget=Input(input_type='number'), validators=[validators.Optional()]) 27 | entity_id = StringField(widget=HiddenInput()) 28 | entity_type = StringField(widget=HiddenInput()) 29 | 30 | def __init__(self, entity_id=None, entity_type=None, **kwargs): 31 | kwargs['entity_id'] = entity_id 32 | kwargs['entity_type'] = entity_type 33 | FlaskForm.__init__(self, **kwargs) 34 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/test_bb_series.py: -------------------------------------------------------------------------------- 1 | import critiquebrainz.db.license as db_license 2 | import critiquebrainz.db.review as db_review 3 | import critiquebrainz.db.users as db_users 4 | from critiquebrainz.db.user import User 5 | from critiquebrainz.frontend.testing import FrontendTestCase 6 | 7 | class SeriesViewsTestCase(FrontendTestCase): 8 | 9 | def setUp(self): 10 | super(SeriesViewsTestCase, self).setUp() 11 | self.user = User(db_users.get_or_create(1, "Tester", new_user_data={ 12 | "display_name": "test user", 13 | })) 14 | self.license = db_license.create( 15 | id='Test', 16 | full_name='Test License', 17 | ) 18 | 19 | def test_series_page(self): 20 | db_review.create( 21 | user_id=self.user.id, 22 | entity_id='e6f48cbd-26de-4c2e-a24a-29892f9eb3be', 23 | entity_type='bb_series', 24 | text='This is a test review', 25 | is_draft=False, 26 | license_id=self.license['id'], 27 | language='en', 28 | ) 29 | response = self.client.get('/series/e6f48cbd-26de-4c2e-a24a-29892f9eb3be') 30 | self.assert200(response) 31 | self.assertIn("Harry Potter", str(response.data)) 32 | # Test if there is a review from test user 33 | self.assertIn('test user', str(response.data)) 34 | 35 | def test_series_page_not_found(self): 36 | # No such mbid returns an error. 37 | response = self.client.get('/series/e6f48cbd-26de-4c2e-a24a-29892f9eb3b1') 38 | self.assert404(response) 39 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/entity/recording.html: -------------------------------------------------------------------------------- 1 | {% extends 'review/entity/base.html' %} 2 | 3 | {% set recording = entity %} 4 | 5 | {% block title %} 6 | {% set recording_title = recording.name | default(_('[Unknown recording]')) %} 7 | {{ _('Review of "%(recording)s" by %(user)s', recording=recording_title, user=review.user.display_name) }} - CritiqueBrainz 8 | {% endblock %} 9 | 10 | {% block entity_title %} 11 |

12 | {% if recording %} 13 | {{ recording.name }} 14 | {% else %} 15 | {{ _('[Unknown recording]') }} 16 | {% endif %} 17 | {% if recording['artists'] is defined and recording['artists'] %} 18 | {% set artist = [] %} 19 | {% for credit in recording['artists'] %} 20 | {% if credit.name %} 21 | {% do artist.append(''|safe % url_for('artist.entity', id=credit.mbid) ~ credit.name ~ ''|safe) %} 22 | {% if credit.join_phrase is defined %} 23 | {% do artist.append(credit.join_phrase) %} 24 | {% endif %} 25 | {% else %} 26 | {% do artist.append(credit) %} 27 | {% endif %} 28 | {% endfor %} 29 | {{ _('by') }} {{ artist|join() }} 30 | {% endif %} 31 |

32 | {% endblock %} 33 | 34 | {% block show_entity_type %} 35 |

36 | 37 | {{ _('MusicBrainz Recording') }} {{ _('View on MusicBrainz') }} 38 |

39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /docker/docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | volumes: 2 | cb_home: 3 | cb_postgres: 4 | 5 | services: 6 | 7 | db: 8 | image: postgres:12.3 9 | user: postgres 10 | environment: 11 | POSTGRES_USER: postgres 12 | POSTGRES_PASSWORD: postgres 13 | volumes: 14 | - cb_postgres:/var/lib/postgresql/data:z 15 | - ./pg_custom:/docker-entrypoint-initdb.d/ 16 | ports: 17 | - "127.0.0.1:15432:5432" 18 | 19 | critiquebrainz: 20 | build: 21 | context: .. 22 | dockerfile: ./Dockerfile 23 | target: critiquebrainz-dev 24 | volumes: 25 | - ../:/code:z 26 | - ../data/app:/data:z 27 | - cb_home:/root 28 | environment: 29 | FLASK_APP: critiquebrainz.frontend 30 | FLASK_ENV: development 31 | ports: 32 | - "8200:8200" 33 | depends_on: 34 | - db 35 | - critiquebrainz_redis 36 | - musicbrainz_db 37 | - static_builder 38 | command: python3 manage.py runserver -h 0.0.0.0 -p 8200 -d 39 | 40 | critiquebrainz_redis: 41 | image: redis:4.0-alpine 42 | 43 | musicbrainz_db: 44 | image: metabrainz/musicbrainz-test-database:beta 45 | volumes: 46 | - ../data/mbdata:/var/lib/postgresql/data/pgdata:z 47 | environment: 48 | PGDATA: /var/lib/postgresql/data/pgdata 49 | MB_IMPORT_DUMPS: "true" 50 | POSTGRES_HOST_AUTH_METHOD: "trust" 51 | ports: 52 | - "127.0.0.1:25432:5432" 53 | 54 | static_builder: 55 | build: 56 | context: .. 57 | dockerfile: Dockerfile.webpack 58 | command: npm run dev 59 | volumes: 60 | - ../critiquebrainz:/code/critiquebrainz:z 61 | -------------------------------------------------------------------------------- /critiquebrainz/ws/user/test/views_test.py: -------------------------------------------------------------------------------- 1 | from critiquebrainz.db import users as db_users 2 | from critiquebrainz.db.user import User 3 | from critiquebrainz.ws.testing import WebServiceTestCase 4 | 5 | 6 | class UserViewsTestCase(WebServiceTestCase): 7 | def test_user_count(self): 8 | resp = self.client.get('/user/') 9 | self.assertDictEqual(resp.json, dict( 10 | count=0, 11 | limit=50, 12 | offset=0, 13 | users=[], 14 | )) 15 | 16 | def test_user_addition(self): 17 | db_users.create( 18 | display_name='Tester 1', 19 | email='tester1@tesing.org', 20 | ) 21 | resp = self.client.get('/user/').json 22 | self.assertEqual(resp['count'], 1) 23 | self.assertEqual(len(resp['users']), 1) 24 | # TODO(roman): Completely verify output (I encountered unicode issues when tried to do that). 25 | 26 | def test_user_entity_handler(self): 27 | user = User(db_users.create( 28 | display_name='Tester 1', 29 | musicbrainz_username='tester1', 30 | email='tester1@testing.org', 31 | )) 32 | resp = self.client.get('/user/{user_id}'.format(user_id=user.id)).json 33 | self.assertEqual(resp['user']['display_name'], 'Tester 1') 34 | 35 | resp2 = self.client.get('/user/{username}'.format(username=user.musicbrainz_username)).json 36 | self.assertEqual(resp2['user']['display_name'], 'Tester 1') 37 | # Test if user with specified ID does not exist 38 | self.assert404(self.client.get('/user/e7aad618-fd86-3983-9e77-405e21796eca')) 39 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/login/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Package login provides authentication functionality for CritiqueBrainz. 3 | 4 | It is based on OAuth2 protocol. MusicBrainz is the only supported provider. 5 | """ 6 | from functools import wraps 7 | 8 | from flask import redirect, url_for 9 | from flask_babel import lazy_gettext, gettext 10 | from flask_login import LoginManager, current_user 11 | from werkzeug.exceptions import Unauthorized 12 | 13 | import critiquebrainz.db.users as db_users 14 | from critiquebrainz.data.mixins import AnonymousUser 15 | from critiquebrainz.db.user import User 16 | 17 | mb_auth = None 18 | 19 | login_manager = LoginManager() 20 | login_manager.login_view = 'login.index' 21 | login_manager.login_message = gettext("Please sign in to access this page.") 22 | login_manager.localize_callback = gettext 23 | login_manager.anonymous_user = AnonymousUser 24 | 25 | 26 | @login_manager.user_loader 27 | def load_user(user_id): 28 | user = db_users.get_by_id(user_id) 29 | if not user: 30 | return None 31 | return User(user) 32 | 33 | 34 | def login_forbidden(f): 35 | @wraps(f) 36 | def decorated(*args, **kwargs): 37 | if current_user.is_anonymous is False: 38 | return redirect(url_for('frontend.index')) 39 | return f(*args, **kwargs) 40 | 41 | return decorated 42 | 43 | 44 | def admin_view(f): 45 | @wraps(f) 46 | def decorated(*args, **kwargs): 47 | if not current_user.is_admin(): 48 | raise Unauthorized(lazy_gettext('You must be an administrator to view this page.')) 49 | return f(*args, **kwargs) 50 | 51 | return decorated 52 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/user/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}{{ user.display_name }} - CritiqueBrainz{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

{{ user.display_name }}

9 | {% if current_user.is_authenticated %} 10 | {% if current_user == user %} 11 | 13 | {{ _('Edit profile') }} 14 | 15 | {% elif current_user.is_admin() %} 16 | 18 | 19 | {{ ('Unblock User' if user.is_blocked else 'Block User') }} 20 | 21 | {% endif %} 22 | {% endif %} 23 |
24 | 25 | 29 | 30 | {% block profile_content %}{% endblock %} 31 |
32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/test_bb_author.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import critiquebrainz.db.license as db_license 4 | import critiquebrainz.db.review as db_review 5 | import critiquebrainz.db.users as db_users 6 | from critiquebrainz.db.user import User 7 | from critiquebrainz.frontend.testing import FrontendTestCase 8 | 9 | 10 | class AuthorViewsTestCase(FrontendTestCase): 11 | 12 | def setUp(self): 13 | super(AuthorViewsTestCase, self).setUp() 14 | self.user = User(db_users.get_or_create(1, "Tester", new_user_data={ 15 | "display_name": "test user", 16 | })) 17 | self.license = db_license.create( 18 | id='Test', 19 | full_name='Test License', 20 | ) 21 | 22 | def test_author_page(self): 23 | db_review.create( 24 | user_id=self.user.id, 25 | entity_id='5df290b8-ecd5-44fb-8d05-70e291133688', 26 | entity_type='bb_author', 27 | text='This is a test review', 28 | is_draft=False, 29 | license_id=self.license['id'], 30 | language='en', 31 | ) 32 | response = self.client.get('/author/5df290b8-ecd5-44fb-8d05-70e291133688') 33 | self.assert200(response) 34 | self.assertIn("Charles Dickens", str(response.data)) 35 | # Test if there is a review from test user 36 | self.assertIn('test user', str(response.data)) 37 | 38 | def test_author_page_not_found(self): 39 | # No such mbid returns an error. 40 | response = self.client.get('/author/5df290b8-ecd5-44fb-8d05-70e291133631') 41 | self.assert404(response) 42 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/review/modify/write.html: -------------------------------------------------------------------------------- 1 | {% extends 'review/modify/base.html' %} 2 | 3 | {% block title %} 4 | {{ _('Write review of "%(entity)s"', entity=entity_title) }} - CritiqueBrainz 5 | {% endblock %} 6 | 7 | {% block header %} 8 |

{{ _('Write review') }}

9 | {% endblock %} 10 | 11 | {% block license_agreement_input %} 12 |
13 |
14 |
15 | 25 |
26 |
27 |
28 | {% endblock %} 29 | 30 | {% block buttons %} 31 | 32 | 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/templates/log/log_results.html: -------------------------------------------------------------------------------- 1 | {% macro link_user(user) %} 2 | {{ user.display_name }} 3 | {% endmacro %} 4 | 5 | {% macro link_review(review) %} 6 | {{ _('review') }} 7 | {% endmacro %} 8 | 9 | {% for date, logs in results %} 10 |
11 |

{{ date }}

12 |
    13 | {% for log in logs %} 14 |
  • 15 | {{ log.timestamp.strftime('%I:%M %p') }} - 16 | {% if log.action == "hide_review" %} 17 | {{ 18 | _('%(admin)s hid a %(review)s written by %(user)s', 19 | admin=link_user(log.admin), review=link_review(log.review), user=link_user(log.review.user)) 20 | }} 21 | {% elif log.action == "unhide_review" %} 22 | {{ 23 | _('%(admin)s unhid a %(review)s written by %(user)s', 24 | admin=link_user(log.admin), review=link_review(log.review), user=link_user(log.review.user)) 25 | }} 26 | {% elif log.action == "block_user" %} 27 | {{ 28 | _('%(admin)s blocked %(user)s', 29 | admin=link_user(log.admin), user=link_user(log.user)) 30 | }} 31 | {% elif log.action == "unblock_user" %} 32 | {{ 33 | _('%(admin)s unblocked %(user)s', 34 | admin=link_user(log.admin), user=link_user(log.user)) 35 | }} 36 | {% endif %} 37 |

    {{ _('Reason:') }} {{ log.reason }}

    38 |
  • 39 | {% endfor %} 40 |
41 |
42 | {% endfor %} 43 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/test_recording.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | import critiquebrainz.db.license as db_license 4 | import critiquebrainz.db.review as db_review 5 | import critiquebrainz.db.users as db_users 6 | import critiquebrainz.frontend.external.musicbrainz_db.recording as mb_recording 7 | from critiquebrainz.db.user import User 8 | from critiquebrainz.frontend.testing import FrontendTestCase 9 | 10 | 11 | class RecordingViewsTestCase(FrontendTestCase): 12 | 13 | def setUp(self): 14 | super(RecordingViewsTestCase, self).setUp() 15 | self.user = User(db_users.get_or_create(1, "Tester", new_user_data={"display_name": "test user"})) 16 | self.license = db_license.create(id='Test', full_name='Test License') 17 | 18 | def test_recording_page(self): 19 | db_review.create( 20 | user_id=self.user.id, 21 | entity_id='442ddce2-ffa1-4865-81d2-b42c40fec7c5', 22 | entity_type='recording', 23 | text='This is a test review', 24 | is_draft=False, 25 | license_id=self.license['id'], 26 | language='en', 27 | ) 28 | response = self.client.get('/recording/442ddce2-ffa1-4865-81d2-b42c40fec7c5') 29 | self.assert200(response) 30 | self.assertIn('Dream Come True', str(response.data)) 31 | # Test if there is a review from test user 32 | self.assertIn('test user', str(response.data)) 33 | 34 | def test_recording_page_not_found(self): 35 | # No such mbid returns an error. 36 | response = self.client.get('/recording/442ddce2-ffa1-4865-81d2-b42c40feffff') 37 | self.assert404(response) 38 | -------------------------------------------------------------------------------- /critiquebrainz/db/comment_revision.py: -------------------------------------------------------------------------------- 1 | # critiquebrainz - Repository for Creative Commons licensed reviews 2 | # 3 | # Copyright (C) 2018 MetaBrainz Foundation Inc. 4 | # 5 | # This program is free software; you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation; either version 2 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License along 16 | # with this program; if not, write to the Free Software Foundation, Inc., 17 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 18 | 19 | import sqlalchemy 20 | 21 | import critiquebrainz.db as db 22 | 23 | 24 | def create(conn, comment_id, text): 25 | """ Create a new revision for comment with specified ID. 26 | 27 | Args: 28 | comment_id (uuid): the ID of the comment for which revision is to be created. 29 | text (str): the text of the new revision. 30 | 31 | Returns: 32 | int: the ID of the new revision created. 33 | """ 34 | result = conn.execute(sqlalchemy.text(""" 35 | INSERT INTO comment_revision (comment_id, text) 36 | VALUES (:comment_id, :text) 37 | RETURNING id 38 | """), { 39 | 'comment_id': comment_id, 40 | 'text': text, 41 | }) 42 | 43 | return result.fetchone().id 44 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/babel.py: -------------------------------------------------------------------------------- 1 | from flask import g, request 2 | from flask_babel import Babel, Locale 3 | 4 | 5 | def init_app(app, domain='messages'): 6 | app.config['LANGUAGES'] = {} 7 | for language in app.config['SUPPORTED_LANGUAGES']: 8 | app.config['LANGUAGES'][language] = Locale.parse(language).language_name 9 | 10 | @app.after_request 11 | def call_after_request_callbacks(response): # pylint: disable=unused-variable 12 | for callback in getattr(g, 'after_request_callbacks', ()): 13 | callback(response) 14 | return response 15 | 16 | def after_this_request(f): 17 | if not hasattr(g, 'after_request_callbacks'): 18 | g.after_request_callbacks = [] 19 | g.after_request_callbacks.append(f) 20 | return f 21 | 22 | def get_locale(): # pylint: disable=unused-variable 23 | supported_languages = app.config['SUPPORTED_LANGUAGES'] 24 | language_arg = request.args.get('l') 25 | if language_arg is not None: 26 | if language_arg in supported_languages: 27 | @after_this_request 28 | def remember_language(response): # pylint: disable=unused-variable 29 | response.set_cookie('language', language_arg) 30 | 31 | return language_arg 32 | else: 33 | language_cookie = request.cookies.get('language') 34 | if language_cookie in supported_languages: 35 | return language_cookie 36 | 37 | return request.accept_languages.best_match(supported_languages) 38 | 39 | babel = Babel() 40 | babel.init_app(app, default_domain=domain, locale_selector=get_locale) 41 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/test_release_group.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import critiquebrainz.db.license as db_license 4 | import critiquebrainz.db.review as db_review 5 | import critiquebrainz.db.users as db_users 6 | from critiquebrainz.db.user import User 7 | from critiquebrainz.frontend.testing import FrontendTestCase 8 | 9 | 10 | class ReleaseGroupViewsTestCase(FrontendTestCase): 11 | 12 | def setUp(self): 13 | super(ReleaseGroupViewsTestCase, self).setUp() 14 | self.user = User(db_users.get_or_create(1, "Tester", new_user_data={ 15 | "display_name": "test user", 16 | })) 17 | self.license = db_license.create( 18 | id='Test', 19 | full_name='Test License', 20 | ) 21 | 22 | def test_release_group_page(self): 23 | db_review.create( 24 | user_id=self.user.id, 25 | entity_id='1eff4a06-056e-4dc7-91c4-0cbc5878f3c3', 26 | entity_type='release_group', 27 | text='This is a test review', 28 | is_draft=False, 29 | license_id=self.license['id'], 30 | language='en', 31 | ) 32 | response = self.client.get('/release-group/1eff4a06-056e-4dc7-91c4-0cbc5878f3c3') 33 | self.assert200(response) 34 | self.assertIn('Strobe Light', str(response.data)) 35 | # Test if there is a review from test user 36 | self.assertIn('test user', str(response.data)) 37 | 38 | def test_releasegroup_page_not_found(self): 39 | # No such mbid returns an error. 40 | response = self.client.get('/release-group/1eff4a06-056e-4dc7-91c4-0cbc58780000') 41 | self.assert404(response) -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/test_bb_literary_work.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import critiquebrainz.db.license as db_license 4 | import critiquebrainz.db.review as db_review 5 | import critiquebrainz.db.users as db_users 6 | from critiquebrainz.db.user import User 7 | from critiquebrainz.frontend.testing import FrontendTestCase 8 | 9 | class LiteraryWorkViewsTestCase(FrontendTestCase): 10 | 11 | def setUp(self): 12 | super(LiteraryWorkViewsTestCase, self).setUp() 13 | self.user = User(db_users.get_or_create(1, "Tester", new_user_data={ 14 | "display_name": "test user", 15 | })) 16 | self.license = db_license.create( 17 | id='Test', 18 | full_name='Test License', 19 | ) 20 | 21 | def test_literary_work_page(self): 22 | db_review.create( 23 | user_id=self.user.id, 24 | entity_id='0e03bc2a-2867-4687-afee-e211ece30772', 25 | entity_type='bb_literary_work', 26 | text='This is a test review', 27 | is_draft=False, 28 | license_id=self.license['id'], 29 | language='en', 30 | ) 31 | response = self.client.get('/literary-work/0e03bc2a-2867-4687-afee-e211ece30772') 32 | self.assert200(response) 33 | self.assertIn("Oliver Twist", str(response.data)) 34 | # Test if there is a review from test user 35 | self.assertIn('test user', str(response.data)) 36 | 37 | def test_literary_work_page_not_found(self): 38 | # No such mbid returns an error. 39 | response = self.client.get('/literary-work/56efa555-abd5-4ccb-89a6-ff9d9021971s') 40 | self.assert404(response) 41 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/views/test/test_bb_edition_group.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import critiquebrainz.db.license as db_license 4 | import critiquebrainz.db.review as db_review 5 | import critiquebrainz.db.users as db_users 6 | from critiquebrainz.db.user import User 7 | from critiquebrainz.frontend.testing import FrontendTestCase 8 | 9 | class EditionGroupViewsTestCase(FrontendTestCase): 10 | 11 | def setUp(self): 12 | super(EditionGroupViewsTestCase, self).setUp() 13 | self.user = User(db_users.get_or_create(1, "Tester", new_user_data={ 14 | "display_name": "test user", 15 | })) 16 | self.license = db_license.create( 17 | id='Test', 18 | full_name='Test License', 19 | ) 20 | 21 | def test_edition_group_page(self): 22 | db_review.create( 23 | user_id=self.user.id, 24 | entity_id='9f49df73-8ee5-4c5f-8803-427c9b216d8f', 25 | entity_type='bb_edition_group', 26 | text='This is a test review', 27 | is_draft=False, 28 | license_id=self.license['id'], 29 | language='en', 30 | ) 31 | response = self.client.get('/edition-group/9f49df73-8ee5-4c5f-8803-427c9b216d8f') 32 | self.assert200(response) 33 | self.assertIn('Harry Potter and the Deathly Hallows', str(response.data)) 34 | # Test if there is a review from test user 35 | self.assertIn('test user', str(response.data)) 36 | 37 | def test_editiongroup_page_not_found(self): 38 | # No such mbid returns an error. 39 | response = self.client.get('/edition-group/9f49df73-8ee5-4c5f-8803-427c9b2160000') 40 | self.assert404(response) -------------------------------------------------------------------------------- /critiquebrainz/db/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_debugtoolbar.panels import sqlalchemy 2 | from sqlalchemy import create_engine, text 3 | from sqlalchemy.pool import NullPool 4 | 5 | # This value must be incremented after schema changes on exported tables! 6 | SCHEMA_VERSION = 15 7 | 8 | VALID_RATING_VALUES = [None, 1, 2, 3, 4, 5] 9 | REVIEW_RATING_MAX = 5 10 | REVIEW_RATING_MIN = 1 11 | REVIEW_TEXT_MAX_LENGTH = 100000 12 | REVIEW_TEXT_MIN_LENGTH = 25 13 | # Scales for rating conversion 14 | RATING_SCALE_0_100 = {1: 20, 2: 40, 3: 60, 4: 80, 5: 100} 15 | RATING_SCALE_1_5 = {20: 1, 40: 2, 60: 3, 80: 4, 100: 5} 16 | 17 | engine = None 18 | 19 | 20 | def init_db_engine(connect_str): 21 | global engine 22 | engine = create_engine(connect_str, poolclass=NullPool) 23 | 24 | 25 | def run_sql_script(sql_file_path): 26 | with open(sql_file_path) as sql, engine.begin() as connection: 27 | connection.execute(text(sql.read())) 28 | 29 | 30 | def run_sql_script_without_transaction(sql_file_path): 31 | with open(sql_file_path) as sql, engine.connect().execution_options(isolation_level="AUTOCOMMIT") as connection: 32 | lines = sql.read().splitlines() 33 | try: 34 | for line in lines: 35 | # TODO: Not a great way of removing comments. The alternative is to catch 36 | # the exception sqlalchemy.exc.ProgrammingError "can't execute an empty query" 37 | if line and not line.startswith("--"): 38 | connection.execute(text(line)) 39 | except sqlalchemy.exc.ProgrammingError as e: 40 | print("Error: {}".format(e)) 41 | return False 42 | finally: 43 | connection.close() 44 | return True 45 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/external/bookbrainz.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from critiquebrainz.frontend.external.bookbrainz_db.edition_group import fetch_multiple_edition_groups 3 | from critiquebrainz.frontend.external.bookbrainz_db.literary_work import fetch_multiple_literary_works 4 | from critiquebrainz.frontend.external.bookbrainz_db.author import fetch_multiple_authors 5 | from critiquebrainz.frontend.external.bookbrainz_db.series import fetch_multiple_series 6 | 7 | 8 | BASE_URL = 'https://bookbrainz.org/search/search' 9 | 10 | MAP_BB_ENTITY_TYPE = { 11 | 'bb_edition_group': 'EditionGroup', 12 | 'bb_literary_work': 'Work', 13 | 'bb_author': 'Author', 14 | 'bb_series': 'Series', 15 | } 16 | 17 | def fetch_bb_data(entity_type, bbids): 18 | if entity_type == 'bb_edition_group': 19 | return fetch_multiple_edition_groups(bbids).values() 20 | elif entity_type == 'bb_literary_work': 21 | return fetch_multiple_literary_works(bbids).values() 22 | elif entity_type == 'bb_author': 23 | return fetch_multiple_authors(bbids).values() 24 | elif entity_type == 'bb_series': 25 | return fetch_multiple_series(bbids).values() 26 | 27 | 28 | def search_bookbrainz_entities(entity_type, query='', limit=None, offset=None): 29 | bb_entity_type = MAP_BB_ENTITY_TYPE[entity_type] 30 | params = {'q': query, 'type': bb_entity_type, 'size': limit, 'from': offset} 31 | data = requests.get(BASE_URL, params=params, timeout=5) 32 | data.raise_for_status() 33 | data = data.json() 34 | count = data['total'] 35 | results = data['results'] 36 | bbids = [result["bbid"] for result in results] 37 | entity_data = fetch_bb_data(entity_type, bbids) 38 | return count, entity_data 39 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/boostrap/code.less: -------------------------------------------------------------------------------- 1 | // 2 | // Code (inline and block) 3 | // -------------------------------------------------- 4 | 5 | 6 | // Inline and block code styles 7 | code, 8 | kbd, 9 | pre, 10 | samp { 11 | font-family: @font-family-monospace; 12 | } 13 | 14 | // Inline code 15 | code { 16 | padding: 2px 4px; 17 | font-size: 90%; 18 | color: @code-color; 19 | background-color: @code-bg; 20 | border-radius: @border-radius-base; 21 | } 22 | 23 | // User input typically entered via keyboard 24 | kbd { 25 | padding: 2px 4px; 26 | font-size: 90%; 27 | color: @kbd-color; 28 | background-color: @kbd-bg; 29 | border-radius: @border-radius-small; 30 | box-shadow: inset 0 -1px 0 rgba(0,0,0,.25); 31 | 32 | kbd { 33 | padding: 0; 34 | font-size: 100%; 35 | font-weight: bold; 36 | box-shadow: none; 37 | } 38 | } 39 | 40 | // Blocks of code 41 | pre { 42 | display: block; 43 | padding: ((@line-height-computed - 1) / 2); 44 | margin: 0 0 (@line-height-computed / 2); 45 | font-size: (@font-size-base - 1); // 14px to 13px 46 | line-height: @line-height-base; 47 | word-break: break-all; 48 | word-wrap: break-word; 49 | color: @pre-color; 50 | background-color: @pre-bg; 51 | border: 1px solid @pre-border-color; 52 | border-radius: @border-radius-base; 53 | 54 | // Account for some code outputs that place code tags in pre tags 55 | code { 56 | padding: 0; 57 | font-size: inherit; 58 | color: inherit; 59 | white-space: pre-wrap; 60 | background-color: transparent; 61 | border-radius: 0; 62 | } 63 | } 64 | 65 | // Enable scrollable blocks of code 66 | .pre-scrollable { 67 | max-height: @pre-scrollable-max-height; 68 | overflow-y: scroll; 69 | } 70 | -------------------------------------------------------------------------------- /critiquebrainz/frontend/static/styles/theme/links.less: -------------------------------------------------------------------------------- 1 | a { 2 | color: @link-color; 3 | &:hover { color: @link-hover-color; } 4 | &:hover, &:focus { text-decoration: underline; } 5 | 6 | &:visited:not(.btn) { 7 | color: @link-visited-color; 8 | &:hover { color: @link-visited-hover-color; } 9 | } 10 | 11 | // Exceptions to :visited state 12 | &.list-group-item:visited { 13 | color: @list-group-link-color; 14 | &.active, &.active:hover, &.active:focus { color: @list-group-active-color; } 15 | &:hover { color: @list-group-link-color; } 16 | } 17 | .navbar-default &.navbar-brand:visited { color: @navbar-default-brand-color; } 18 | .navbar-inverse &.navbar-brand:visited { color: @navbar-inverse-brand-color; } 19 | .nav-tabs > li > &:visited { color: @link-color; } 20 | .nav-pills > li > &:visited { color: @link-color; } 21 | .nav-pills > li.active > &:visited { 22 | &, &:hover, &:focus { color: @nav-pills-active-link-hover-color; } 23 | } 24 | .dropdown-menu > li > &:visited { 25 | color: @dropdown-link-color; 26 | &:hover, 27 | &:focus { 28 | color: @dropdown-link-hover-color; 29 | background-color: @dropdown-link-hover-bg; 30 | } 31 | } 32 | .dropdown-menu > li.disabled > &:visited { 33 | &, &:hover, &:focus { color: @dropdown-link-disabled-color; } 34 | } 35 | .pager > li > &:visited, .pagination > li > &:visited { 36 | &, &:hover, &:focus { color: @link-color; } 37 | } 38 | .pagination > .active > &:visited { 39 | &, &:hover, &:focus { color: @pagination-active-color; } 40 | } 41 | .pagination > .disabled > &:visited { 42 | &, &:hover, &:focus { color: @pagination-disabled-color; } 43 | } 44 | &.alert-link:visited { 45 | color: inherit; 46 | } 47 | } 48 | --------------------------------------------------------------------------------