diff --git a/.gitignore b/.gitignore index 4988832..4942fc7 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ db.sqlite3-journal # Flask stuff: instance/ .webassets-cache +flask_session/ # Scrapy stuff: .scrapy @@ -154,3 +155,4 @@ accounts/deploy/env_values.txt *.key *.cer +/.bootstrap diff --git a/.localenv b/.localenv new file mode 100644 index 0000000..20b5389 --- /dev/null +++ b/.localenv @@ -0,0 +1,9 @@ +export BASE_SERVER='localhost.arxiv.org' +export FLASK_APP=admin_webapp/app.py +export CLASSIC_DB_URI=sqlite:///test.db +export DEFAULT_LOGIN_REDIRECT_URL='/protected' +export AUTH_SESSION_COOKIE_DOMAIN='localhost.arxiv.org' +export CLASSIC_COOKIE_NAME='LOCALHOST_DEV_admin_webapp_classic_cookie' +export AUTH_SESSION_COOKIE_SECURE=0 +export DEFAULT_LOGOUT_REDIRECT_URL='/login' +export REDIS_FAKE=1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e0c8c7f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +FROM python:3.11.8-bookworm +RUN apt-get update && apt-get -y upgrade + +ENV PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=off \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=100 \ + POETRY_VERSION=1.3.2 \ + TRACE=1 \ + LC_ALL=en_US.utf8 \ + LANG=en_US.utf8 \ + APP_HOME=/app + +WORKDIR /app + +RUN apt-get -y install default-libmysqlclient-dev + +ENV VIRTUAL_ENV=/opt/venv +RUN python -m venv $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" +RUN pip install -U pip "poetry==$POETRY_VERSION" + +COPY poetry.lock pyproject.toml ./ +RUN poetry config virtualenvs.create false && \ + poetry install --no-interaction --no-ansi + +RUN pip install "gunicorn==20.1.0" + +ADD admin_webapp /app/admin_webapp +ADD tests /app/tests + +EXPOSE 8080 + +RUN useradd e-prints +RUN chown e-prints:e-prints /app/tests/data/ +RUN chmod 775 /app/tests/data/ +USER e-prints + +ENV GUNICORN gunicorn --bind :8080 \ + --workers 4 --threads 8 --timeout 0 \ + "admin_webapp.factory:create_web_app()" + +CMD exec $GUNICORN \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..eae7088 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +PYTHON := python3.11 + +default: .bootstrap + +venv: + ${PYTHON} -m venv venv + . venv/bin/activate && pip install --upgrade pip + . venv/bin/activate && pip install poetry + . venv/bin/activate && poetry install + +.bootstrap: venv + touch .bootstrap + +test.db: .bootstrap + #. venv/bin/activate && . ./.localenv && poetry run python create_user.py create-user --password boguspassword --username bob --email bogus@example.com --first-name Bob --last-name Bogus --suffix-name '' --affiliation FSU --home-page https://asdf.com + . venv/bin/activate && . ./.localenv && poetry run python create_user.py load-users tests/fixtures/bogus.yaml + + +run: test.db + script/run_local.sh diff --git a/README.md b/README.md index a96418b..43803f4 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,15 @@ This repo provides a web app for for admin tools, forms, reports and APIs. # How to get started ```bash +cd $HOME/arxiv # Or, your desired location +git clone git@github.com:arXiv/admin-webapp.git cd admin-webapp -pip install poetry -poetry install # installs to a venv -poetry shell # activates the venv -LOCALHOST_DEV=1 \ - python create_user.py # fill out a test user -LOCALHOST_DEV=1 \ - FLASK_APP=admin_webapp/app.py \ - flask run +# git switch tapir-dev +make run ``` + +Makefile sets up the "venv" and runs script/run_local.sh after bootstrapping the environment. + Then go to http://localhost.arxiv.org:5000/login and log in with the user and pw you just created. To use with MySQL: @@ -30,6 +29,12 @@ create tables. Conventional read/write access should be sufficient. You should be able to go to a page like http://localhost:5000/login or http://localhost:5000/register +While developing, it's best to open up dev.arxiv.org/admin for legacy Tapir so you can make changes freely. In some cases it can be helpful to open up production Tapir at arxiv.org/admin, but tread carefully so you don't unintentionally modify a user profile. In most cases, however, there isn't too much to worry about. + +# Database Schema + +Since there are several db schemata running around, this application makes some assumptions. They are a) a Moderators table in associative_tables.py in `arxiv-db` cannot exist and b) in the tapir_users.py file in `arxiv-db` the tapir_nickanames field needs to be one to one which means `useList` needs to be set to `false`. + # Running the tests After setting up you should be able to run the tests with diff --git a/admin_webapp/admin_log.py b/admin_webapp/admin_log.py index a875d50..dcabe23 100644 --- a/admin_webapp/admin_log.py +++ b/admin_webapp/admin_log.py @@ -6,7 +6,7 @@ from flask import current_app, request -from arxiv_db.models import TapirAdminAudit +from arxiv.db.models import TapirAdminAudit Actions = Literal['new-user', diff --git a/admin_webapp/config.py b/admin_webapp/config.py index 6a730ad..2cd9f11 100644 --- a/admin_webapp/config.py +++ b/admin_webapp/config.py @@ -1,136 +1,98 @@ """Flask configuration.""" - -import os +from typing import Optional, List, Tuple import re -from zoneinfo import ZoneInfo +from arxiv.config import Settings as BaseSettings + +class Settings (BaseSettings): + + BASE_SERVER: str = "arxiv.org" + SERVER_NAME: str = BASE_SERVER + + REDIS_HOST: str = 'localhost' + REDIS_PORT: int = 7000 + REDIS_DATABASE: str = '0' + REDIS_TOKEN: Optional[str] = None + """This is the token used in the AUTH procedure.""" + REDIS_CLUSTER: bool = True + + REDIS_FAKE: bool = False + """Use the FakeRedis library instead of a redis service. + + Useful for testing, dev, beta.""" + + JWT_SECRET: str = 'foosecret' + + DEFAULT_LOGIN_REDIRECT_URL: str = 'https://arxiv.org/user' + DEFAULT_LOGOUT_REDIRECT_URL: str = 'https://arxiv.org' + + LOGIN_REDIRECT_REGEX: str = fr'(/.*)|(https://([a-zA-Z0-9\-.])*{re.escape(BASE_SERVER)}/.*)' + """Regex to check next_page of /login. + + Only next_page values that match this regex will be allowed. All + others will go to the DEFAULT_LOGOUT_REDIRECT_URL. The default value + for this allows relative URLs and URLs to subdomains of the + BASE_SERVER. + """ + URLS: List[Tuple[str, str, str]] = [ + ("lost_password", "/user/lost_password", BASE_SERVER), + ("account", "/user", BASE_SERVER) + ] + + AUTH_SESSION_COOKIE_NAME: str = 'ARXIVNG_SESSION_ID' + AUTH_SESSION_COOKIE_DOMAIN: str = '.arxiv.org' + AUTH_SESSION_COOKIE_SECURE: bool = True + + CLASSIC_COOKIE_NAME: str = 'tapir_session' + CLASSIC_PERMANENT_COOKIE_NAME: str = 'tapir_permanent' + + CLASSIC_TRACKING_COOKIE: str = 'browser' + CLASSIC_TOKEN_RECOVERY_TIMEOUT: int = 86400 + + CLASSIC_SESSION_HASH: str = 'foosecret' + SESSION_DURATION: int = 36000 + + CAPTCHA_SECRET: str = 'foocaptcha' + """Used to encrypt captcha answers, so that we don't need to store them.""" + + CAPTCHA_FONT: Optional[str] = None + + CREATE_DB: bool = False + + AUTH_UPDATED_SESSION_REF: bool = True # see ARXIVNG-1920 -BASE_SERVER = os.environ.get('BASE_SERVER', 'arxiv.org') + LOCALHOST_DEV: bool = False + """Enables a set of config vars that facilites development on localhost""" -SECRET_KEY = os.environ.get('SECRET_KEY', 'asdf1234') -"""SECRET_KEY used for flask sessions.""" + WTF_CSRF_ENABLED: bool = False + """Enable CSRF. -SESSION_COOKIE_PATH = os.environ.get('APPLICATION_ROOT', '/') + Do not disable in production.""" -REDIS_HOST = os.environ.get('REDIS_HOST', 'localhost') -REDIS_PORT = os.environ.get('REDIS_PORT', '7000') -REDIS_DATABASE = os.environ.get('REDIS_DATABASE', '0') -REDIS_TOKEN = os.environ.get('REDIS_TOKEN', None) -"""This is the token used in the AUTH procedure.""" -REDIS_CLUSTER = os.environ.get('REDIS_CLUSTER', '1') + WTF_CSRF_EXEMPT: str = 'admin_webapp.routes.ui.login,admin_webapp.routes.ui.logout' + """Comma seperted list of views to not do CSRF protection on. -REDIS_FAKE = os.environ.get('REDIS_FAKE', False) -"""Use the FakeRedis library instead of a redis service. + Login and logout lack the setup for this.""" -Useful for testing, dev, beta.""" + # if LOCALHOST_DEV: + # # Don't want to setup redis just for local developers + # REDIS_FAKE=True + # FLASK_DEBUG=True + # DEBUG=True + # if not SQLALCHEMY_DATABASE_URI: + # # SQLALCHEMY_DATABASE_URI = 'sqlite:///../locahost_dev.db' + # SQLALCHEMY_DATABASE_URI='mysql+mysqldb://root:root@localhost:3306/arXiv' -JWT_SECRET = os.environ.get('JWT_SECRET', 'foosecret') + # # CLASSIC_DATABASE_URI = SQLALCHEMY_DATABASE_URI -DEFAULT_LOGIN_REDIRECT_URL = os.environ.get( - 'DEFAULT_LOGIN_REDIRECT_URL', - 'https://arxiv.org/user' -) -DEFAULT_LOGOUT_REDIRECT_URL = os.environ.get( - 'DEFAULT_LOGOUT_REDIRECT_URL', - 'https://arxiv.org' -) - -LOGIN_REDIRECT_REGEX = os.environ.get('LOGIN_REDIRECT_REGEX', - fr'(/.*)|(https://([a-zA-Z0-9\-.])*{re.escape(BASE_SERVER)}/.*)') -"""Regex to check next_page of /login. - -Only next_page values that match this regex will be allowed. All -others will go to the DEFAULT_LOGOUT_REDIRECT_URL. The default value -for this allows relative URLs and URLs to subdomains of the -BASE_SERVER. -""" - -login_redirect_pattern = re.compile(LOGIN_REDIRECT_REGEX) - -AUTH_SESSION_COOKIE_NAME = 'ARXIVNG_SESSION_ID' -AUTH_SESSION_COOKIE_DOMAIN = os.environ.get('AUTH_SESSION_COOKIE_DOMAIN', '.arxiv.org') -AUTH_SESSION_COOKIE_SECURE = bool(int(os.environ.get('AUTH_SESSION_COOKIE_SECURE', '1'))) - -CLASSIC_COOKIE_NAME = os.environ.get('CLASSIC_COOKIE_NAME', 'tapir_session') -CLASSIC_PERMANENT_COOKIE_NAME = os.environ.get( - 'CLASSIC_PERMANENT_COOKIE_NAME', - 'tapir_permanent' -) -CLASSIC_TRACKING_COOKIE = os.environ.get('CLASSIC_TRACKING_COOKIE', 'browser') -CLASSIC_TOKEN_RECOVERY_TIMEOUT = os.environ.get( - 'CLASSIC_TOKEN_RECOVERY_TIMEOUT', - '86400' -) -CLASSIC_SESSION_HASH = os.environ.get('CLASSIC_SESSION_HASH', 'foosecret') -SESSION_DURATION = os.environ.get( - 'SESSION_DURATION', - '36000' -) - -ARXIV_BUSINESS_TZ = ZoneInfo(os.environ.get('ARXIV_BUSINESS_TZ', 'America/New_York')) - -SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI') -CLASSIC_DATABASE_URI = SQLALCHEMY_DATABASE_URI -SQLALCHEMY_TRACK_MODIFICATIONS = False - -CAPTCHA_SECRET = os.environ.get('CAPTCHA_SECRET', 'foocaptcha') -"""Used to encrypt captcha answers, so that we don't need to store them.""" - -CAPTCHA_FONT = os.environ.get('CAPTCHA_FONT', None) - -URLS = [ - ("lost_password", "/user/lost_password", BASE_SERVER), - ("account", "/user", BASE_SERVER) -] - -CREATE_DB = bool(int(os.environ.get('CREATE_DB', 0))) - - -AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID', 'nope') -AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY', 'nope') -AWS_REGION = os.environ.get('AWS_REGION', 'us-east-1') -FLASKS3_BUCKET_NAME = os.environ.get('FLASKS3_BUCKET_NAME', 'some_bucket') -FLASKS3_CDN_DOMAIN = os.environ.get('FLASKS3_CDN_DOMAIN', 'static.arxiv.org') -FLASKS3_USE_HTTPS = os.environ.get('FLASKS3_USE_HTTPS', 1) -FLASKS3_FORCE_MIMETYPE = os.environ.get('FLASKS3_FORCE_MIMETYPE', 1) -FLASKS3_ACTIVE = os.environ.get('FLASKS3_ACTIVE', 0) -"""Flask-S3 plugin settings.""" - -AUTH_UPDATED_SESSION_REF = True # see ARXIVNG-1920 - -LOCALHOST_DEV = os.environ.get('LOCALHOST_DEV', False) -"""Enables a set of config vars that facilites development on localhost""" - - - -WTF_CSRF_ENABLED = os.environ.get('WTF_CSRF_ENABLED', True) -"""Enable CSRF. - -Do not disable in production.""" - -WTF_CSRF_EXEMPT = os.environ.get('WTF_CSRF_EXEMPT', - '''admin_webapp.routes.ui.login,admin_webapp.routes.ui.logout''') -"""Comma seperted list of views to not do CSRF protection on. - -Login and logout lack the setup for this.""" - -if LOCALHOST_DEV: - # Don't want to setup redis just for local developers - REDIS_FAKE=True - FLASK_DEBUG=True - DEBUG=True - if not SQLALCHEMY_DATABASE_URI: - SQLALCHEMY_DATABASE_URI = 'sqlite:///../locahost_dev.db' - CLASSIC_DATABASE_URI = SQLALCHEMY_DATABASE_URI - - DEFAULT_LOGIN_REDIRECT_URL='/protected' - # Need to use this funny name where we have a DNS entry to 127.0.0.1 - # because browsers will reject cookie domains with fewer than 2 dots - AUTH_SESSION_COOKIE_DOMAIN='localhost.arxiv.org' - # Want to not conflict with any existing cookies for subdomains of arxiv.org - # so give it a different name - CLASSIC_COOKIE_NAME='LOCALHOST_DEV_admin_webapp_classic_cookie' - # Don't want to use HTTPS for local dev - AUTH_SESSION_COOKIE_SECURE=0 - # Redirect to relative pages instead of arxiv.org pages - DEFAULT_LOGOUT_REDIRECT_URL='/login' - DEFAULT_LOGIN_REDIRECT_URL='/protected' + # DEFAULT_LOGIN_REDIRECT_URL='/protected' + # # Need to use this funny name where we have a DNS entry to 127.0.0.1 + # # because browsers will reject cookie domains with fewer than 2 dots + # AUTH_SESSION_COOKIE_DOMAIN='localhost.arxiv.org' + # # Want to not conflict with any existing cookies for subdomains of arxiv.org + # # so give it a different name + # CLASSIC_COOKIE_NAME='LOCALHOST_DEV_admin_webapp_classic_cookie' + # # Don't want to use HTTPS for local dev + # AUTH_SESSION_COOKIE_SECURE=0 + # # Redirect to relative pages instead of arxiv.org pages + # DEFAULT_LOGOUT_REDIRECT_URL='/login' + # DEFAULT_LOGIN_REDIRECT_URL='/protected' diff --git a/admin_webapp/controllers/authentication.py b/admin_webapp/controllers/authentication.py index 0294285..ca685cf 100644 --- a/admin_webapp/controllers/authentication.py +++ b/admin_webapp/controllers/authentication.py @@ -15,7 +15,8 @@ from werkzeug.datastructures import MultiDict from werkzeug.exceptions import InternalServerError -from flask import Markup +from flask import current_app +from markupsafe import Markup from wtforms import StringField, PasswordField, Form from wtforms.validators import DataRequired @@ -24,14 +25,14 @@ from arxiv import status from arxiv.base import logging +from arxiv.db import transaction -from arxiv_auth.domain import User, Authorizations, Session +from arxiv.auth.domain import User, Authorizations, Session -from arxiv_auth.auth.sessions import SessionStore +from arxiv.auth.auth.sessions import SessionStore -from arxiv_auth.legacy import exceptions, sessions as legacy_sessions -from arxiv_auth.legacy.authenticate import authenticate -from arxiv_auth.legacy.util import transaction +from arxiv.auth.legacy import exceptions, sessions as legacy_sessions +from arxiv.auth.legacy.authenticate import authenticate from admin_webapp import config @@ -71,9 +72,11 @@ def login(method: str, form_data: MultiDict, ip: str, # and redirect if successful. Otherwise, proceed as normal without # complaint. if not next_page or good_next_page(next_page): + logger.debug("GOT HERE") response_data = {'form': LoginForm(), 'next_page': next_page} return response_data, status.HTTP_200_OK, {} else: + logger.debug("FAILED< GOT HERE") response_data = {'form': LoginForm(), 'error':'next_page is invalid'} return response_data, status.HTTP_400_BAD_REQUEST, {} @@ -113,7 +116,7 @@ def login(method: str, form_data: MultiDict, ip: str, session = sessions.create(auths, ip, ip, track, user=user) cookie = sessions.generate_cookie(session) logger.debug('Created session: %s', session.session_id) - except sessions.exceptions.SessionCreationFailed as e: + except exceptions.SessionCreationFailed as e: logger.debug('Could not create session: %s', e) raise InternalServerError('Cannot log in') from e # type: ignore @@ -130,7 +133,7 @@ def login(method: str, form_data: MultiDict, ip: str, 'classic_cookie': (c_cookie, c_session.expires) } }) - next_page = next_page if good_next_page(next_page) else config.DEFAULT_LOGIN_REDIRECT_URL + next_page = next_page if good_next_page(next_page) else current_app.config['DEFAULT_LOGIN_REDIRECT_URL'] return data, status.HTTP_303_SEE_OTHER, {'Location': next_page} @@ -218,5 +221,5 @@ def _do_logout(classic_session_cookie: str) -> None: def good_next_page(next_page: str) -> bool: """True if next_page is a valid query parameter for use with the login page.""" - return next_page == config.DEFAULT_LOGIN_REDIRECT_URL \ - or re.search(config.login_redirect_pattern, next_page) + return next_page == current_app.config['DEFAULT_LOGIN_REDIRECT_URL'] \ + or re.search(re.compile(current_app.config['LOGIN_REDIRECT_REGEX']), next_page) diff --git a/admin_webapp/controllers/document.py b/admin_webapp/controllers/document.py new file mode 100644 index 0000000..ddafb99 --- /dev/null +++ b/admin_webapp/controllers/document.py @@ -0,0 +1,38 @@ +from collections import defaultdict +from datetime import datetime, timedelta +import logging +from admin_webapp.routes import endorsement + +from flask import Blueprint, render_template, request, \ + make_response, current_app, Response, abort + +from sqlalchemy import select, func, case, text, insert, update, desc +from sqlalchemy.dialects.mysql import JSON +from sqlalchemy.orm import joinedload, selectinload, aliased +from arxiv.base import logging + +from arxiv.auth.auth.decorators import scoped +from arxiv.db import session +from arxiv.db.models import Document, Metadata, PaperPw + +logger = logging.getLogger(__name__) + +def paper_detail(doc_id:int) -> Response: + doc_stmt = (select(Document).where(Document.document_id == doc_id)) + sub_history_stmt = (select(Metadata).where(Metadata.document_id==doc_id).order_by(desc(Metadata.version))) + doc_pw_stmt = (select(PaperPw).where(PaperPw.document_id == doc_id)) + + document = session.scalar(doc_stmt) + doc_pw = session.scalar(doc_pw_stmt) + sub_history = session.scalars(sub_history_stmt) + + + admin_log_sql = "SELECT created,submission_id,paper_id,username,program,command,logtext FROM arXiv_admin_log WHERE paper_id=:paper_id UNION DISTINCT SELECT arXiv_admin_log.created AS created,arXiv_admin_log.submission_id AS submission_id,paper_id,username,program,command,logtext FROM arXiv_admin_log,arXiv_submissions WHERE arXiv_submissions.submission_id=arXiv_admin_log.submission_id AND doc_paper_id=:paper_id ORDER BY created DESC" + admin_log_sql_len = f"SELECT COUNT(*) FROM ({admin_log_sql}) as subquery" + admin_log = session.execute(admin_log_sql, {"paper_id": document.paper_id}) + + admin_log_len = session.execute(admin_log_sql_len, {"paper_id": document.paper_id}) + data = dict(document=document, doc_pw=doc_pw, sub_history=sub_history, admin_log=admin_log, admin_log_len=admin_log_len.fetchone()[0]) + + return data + diff --git a/admin_webapp/controllers/endorsement.py b/admin_webapp/controllers/endorsement.py new file mode 100644 index 0000000..862d795 --- /dev/null +++ b/admin_webapp/controllers/endorsement.py @@ -0,0 +1,98 @@ +"""arXiv Endorsement controllers.""" +from collections import defaultdict +from datetime import datetime, timedelta +import logging + +from flask import Blueprint, render_template, request, \ + make_response, current_app, Response, abort + +from sqlalchemy import select, func, case, text, insert, update, desc +from sqlalchemy.dialects.mysql import JSON +from sqlalchemy.orm import joinedload, selectinload, aliased +from arxiv.base import logging + +from arxiv.auth.auth.decorators import scoped +from arxiv.db import session +from arxiv.db.models import Endorsement, EndorsementsAudit, EndorsementRequest, Demographic + +from .util import Pagination +logger = logging.getLogger(__name__) + +# blueprint = Blueprint('ownership', __name__, url_prefix='/ownership') +""" +All Endorsement listing +""" +def simple_endorsement_listing(per_page:int, page: int) -> dict: + report_stmt = (select(Endorsement) + # TODO: do I need a joinedload to prevent N+1 queries + # .options(joinedload(TapirUsers.tapir_nicknames)) + .limit(per_page).offset((page -1) * per_page)).join(EndorsementsAudit, isouter=True) + + count_stmt = (select(func.count(Endorsement.endorsement_id))) + + Endorsement = session.scalars(report_stmt) + count = session.execute(count_stmt).scalar_one() + pagination = Pagination(query=None, page=page, per_page=per_page, total=count, items=None) + + + return dict(pagination=pagination, count=count, Endorsement=Endorsement) + +def endorsement_listing(report_type:str, per_page:int, page: int, days_back:int, + flagged:bool, not_positive:bool=False): + """Get data for a list of endorsement requests based on query.""" + # depending on SQLalchemy data model sometimes arXiv_Endorsement is endorsement + report_stmt = (select(EndorsementRequest) + .options(joinedload(EndorsementRequest.endorsee), + joinedload(EndorsementRequest.arXiv_Endorsement).joinedload(Endorsement.endorser),) + .order_by(EndorsementRequest.request_id.desc()) + .limit(per_page).offset((page -1) * per_page)) + count_stmt = (select(func.count(EndorsementRequest.request_id))) + if flagged: + report_stmt = report_stmt.join(Demographic, EndorsementRequest.endorsee_id == Demographic.user_id) + report_stmt = report_stmt.filter(Demographic.flag_suspect == 1) + count_stmt = count_stmt.join(Demographic, EndorsementRequest.endorsee_id == Demographic.user_id) + count_stmt = count_stmt.filter(Demographic.flag_suspect == 1) + + if not_positive: + report_stmt = report_stmt.join(Endorsement, EndorsementRequest.request_id == Endorsement.request_id) + report_stmt = report_stmt.filter(Endorsement.point_value <= 0) + count_stmt = count_stmt.join(Endorsement, EndorsementRequest.request_id == Endorsement.request_id) + count_stmt = count_stmt.filter(Endorsement.point_value <= 0) + + if report_type == 'today': + days_back = 1 + elif not days_back: + days_back = 7 + + window = datetime.now() - timedelta(days=days_back) + report_stmt = report_stmt.filter(EndorsementRequest.issued_when > window) + count_stmt = count_stmt.filter(EndorsementRequest.issued_when > window) + + Endorsement = session.scalars(report_stmt) + count = session.execute(count_stmt).scalar_one() + pagination = Pagination(query=None, page=page, per_page=per_page, total=count, items=None) + return dict(pagination=pagination, count=count, Endorsement=Endorsement, + report_type=report_type, days_back=days_back, not_positive=not_positive) + +""" +Get count information for landing page. +""" +def endorsement_listing_counts_only(report_type:str, flagged:bool=False, not_positive:bool=False): + count_stmt = (select(func.count(EndorsementRequest.request_id))) + if flagged: + count_stmt = count_stmt.join(Demographic, EndorsementRequest.endorsee_id == Demographic.user_id) + count_stmt = count_stmt.filter(Demographic.flag_suspect == 1) + + if not_positive: + count_stmt = count_stmt.join(Endorsement, EndorsementRequest.request_id == Endorsement.request_id) + count_stmt = count_stmt.filter(Endorsement.point_value <= 0) + + if report_type == 'today': + days_back = 1 + else: + days_back = 7 + window = datetime.now() - timedelta(days=days_back) + count_stmt = count_stmt.filter(EndorsementRequest.issued_when > window) + count = session.execute(count_stmt).scalar_one() + + return count \ No newline at end of file diff --git a/admin_webapp/controllers/ownership.py b/admin_webapp/controllers/ownership.py index ac9d8f3..34ba526 100644 --- a/admin_webapp/controllers/ownership.py +++ b/admin_webapp/controllers/ownership.py @@ -1,27 +1,23 @@ """arXiv paper ownership controllers.""" from datetime import datetime, timedelta +from pytz import timezone import logging -from admin_webapp.routes import endorsement -from flask import Blueprint, render_template, request, \ - make_response, current_app, Response, abort +from flask import Blueprint, request, current_app, Response, abort -from flask_sqlalchemy import Pagination - -from sqlalchemy import select, func, text, insert, update -from sqlalchemy.orm import joinedload, selectinload +from sqlalchemy import Integer, String, select, func, text, insert, update +from sqlalchemy.orm import joinedload from arxiv.base import logging +from sqlalchemy.exc import IntegrityError -from arxiv_auth.auth.decorators import scoped - -from arxiv_db.models import OwnershipRequests, OwnershipRequestsAudit, TapirUsers, Documents, EndorsementRequests -from arxiv_db.models.associative_tables import t_arXiv_paper_owners +from arxiv.auth.auth.decorators import scoped +from arxiv.db import session +from arxiv.db.models import OwnershipRequest, OwnershipRequestsAudit, TapirUser, EndorsementRequest, PaperOwner -from admin_webapp.extensions import get_csrf, get_db -from admin_webapp.admin_log import audit_admin +from .util import Pagination -logger = logging.getLogger(__file__) +logger = logging.getLogger(__name__) blueprint = Blueprint('ownership', __name__, url_prefix='/ownership') @@ -43,7 +39,6 @@ def ownership_post(data:dict) -> Response: Note: This doesn't do "bulk" mode """ - session = get_db(current_app).session oreq = data['ownership'] if request.method == 'POST': admin_id = 1234 #request.auth.user.user_id @@ -55,34 +50,39 @@ def ownership_post(data:dict) -> Response: is_author = 1 if request.form['is_author'] else 0 cookie = request.cookies.get(current_app.config['CLASSIC_TRACKING_COOKIE']) - now = int(datetime.now().astimezone(current_app.config['ARXIV_BUSINESS_TZ']).timestamp()) + now = int(datetime.now().astimezone(timezone(current_app.config['ARXIV_BUSINESS_TZ'])).timestamp()) for doc_id in to_add_ownership: - stmt = insert(t_arXiv_paper_owners).values( + session.add(PaperOwner( document_id=doc_id, user_id=oreq.user.user_id, date=now, added_by=admin_id, remote_addr=request.remote_addr, tracking_cookie=cookie, - flag_auto=0, flag_author=is_author) - session.execute(stmt) + flag_auto=0, flag_author=is_author)) #audit_admin(oreq.user_id, 'add-paper-owner-2', doc_id) oreq.workflow_status = 'accepted' - session.execute(update(OwnershipRequests) - .where(OwnershipRequests.request_id == oreq.request_id) + session.execute(update(OwnershipRequest) + .where(OwnershipRequest.request_id == oreq.request_id) .values(workflow_status = 'accepted')) data['success']='accepted' data['success_count'] = len(docs_to_own - already_owns) data['success_already_owned'] = len(docs_to_own & already_owns) elif 'reject' in request.form: - stmt=text("""UPDATE arXiv_ownership_requests SET workflow_status='rejected' - WHERE request_id=:reqid""") - session.execute(stmt, dict(reqid=oreq.request_id)) + stmt = ( + update(OwnershipRequest) + .where(OwnershipRequest.request_id == oreq.request_id) + .values(workflow_status = 'rejected') + ) + session.execute(stmt) data['success']='rejected' elif 'revisit' in request.form: # A revisit does not undo the paper ownership. This the same as legacy. - stmt=text("""UPDATE arXiv_ownership_requests SET workflow_status='pending' - WHERE request_id=:reqid""") - session.execute(stmt, dict(reqid=oreq.request_id)) + stmt = ( + update(OwnershipRequest) + .where(OwnershipRequest.request_id == oreq.request_id) + .values(workflow_status = 'pending') + ) + session.execute(stmt) data['success']='revisited' else: abort(400) @@ -96,30 +96,29 @@ def ownership_detail(ownership_id:int, postfn=None) -> dict: """Display a ownership request. """ - session = get_db(current_app).session - stmt = (select(OwnershipRequests) + stmt = (select(OwnershipRequest) .options( - joinedload(OwnershipRequests.user).joinedload(TapirUsers.tapir_nicknames), - joinedload(OwnershipRequests.user).joinedload(TapirUsers.owned_papers), - joinedload(OwnershipRequests.request_audit), - joinedload(OwnershipRequests.documents), - joinedload(OwnershipRequests.endorsement_request).joinedload(EndorsementRequests.audit) + joinedload(OwnershipRequest.user).joinedload(TapirUser.tapir_nicknames), + joinedload(OwnershipRequest.user).joinedload(TapirUser.owned_papers), + joinedload(OwnershipRequest.request_audit), + joinedload(OwnershipRequest.documents), + joinedload(OwnershipRequest.endorsement_request).joinedload(EndorsementRequest.audit) ) - .where( OwnershipRequests.request_id == ownership_id)) + .where( OwnershipRequest.request_id == ownership_id)) oreq = session.scalar(stmt) if not oreq: abort(404) - already_owns =[paper.paper_id for paper in oreq.user.owned_papers] + already_owns =[paper.document.paper_id for paper in oreq.user.owned_papers] for paper in oreq.documents: setattr(paper, 'already_owns', paper.paper_id in already_owns) endorsement_req = oreq.endorsement_request if oreq.endorsement_request else None data = dict(ownership=oreq, user=oreq.user, - nickname= oreq.user.tapir_nicknames[0].nickname, + nickname= oreq.user.tapir_nicknames.nickname, papers=oreq.documents, - audit=oreq.request_audit[0], + audit=oreq.request_audit, ownership_id=ownership_id, docids = [paper.paper_id for paper in oreq.documents], endorsement_req=endorsement_req,) @@ -131,13 +130,12 @@ def ownership_detail(ownership_id:int, postfn=None) -> dict: def ownership_listing(workflow_status:str, per_page:int, page: int, days_back:int) -> dict: - session = get_db(current_app).session - report_stmt = (select(OwnershipRequests) - .options(joinedload(OwnershipRequests.user)) - .filter(OwnershipRequests.workflow_status == workflow_status) + report_stmt = (select(OwnershipRequest) + .options(joinedload(OwnershipRequest.user)) + .filter(OwnershipRequest.workflow_status == workflow_status) .limit(per_page).offset((page -1) * per_page)) - count_stmt = (select(func.count(OwnershipRequests.request_id)) - .where(OwnershipRequests.workflow_status == workflow_status)) + count_stmt = (select(func.count(OwnershipRequest.request_id)) + .where(OwnershipRequest.workflow_status == workflow_status)) if workflow_status in ('accepted', 'rejected'): window = datetime.now() - timedelta(days=days_back) @@ -148,3 +146,6 @@ def ownership_listing(workflow_status:str, per_page:int, page: int, count = session.execute(count_stmt).scalar_one() pagination = Pagination(query=None, page=page, per_page=per_page, total=count, items=None) return dict(pagination=pagination, count=count, ownership_requests=oreqs, worflow_status=workflow_status, days_back=days_back) + + + diff --git a/admin_webapp/controllers/registration.py b/admin_webapp/controllers/registration.py index ef90f77..84ea159 100644 --- a/admin_webapp/controllers/registration.py +++ b/admin_webapp/controllers/registration.py @@ -8,32 +8,36 @@ affiliation information, and links to external identities such as GitHub and ORCID. """ +from admin_webapp import config from typing import Dict, Tuple, Any, Optional from werkzeug.datastructures import MultiDict from werkzeug.exceptions import BadRequest, InternalServerError -from arxiv import status -from arxiv_auth import domain +from http import HTTPStatus as status +from arxiv.auth import domain from arxiv.base import logging -from arxiv_auth.auth.sessions import SessionStore +from arxiv.auth.auth.sessions import SessionStore from wtforms import StringField, PasswordField, SelectField, \ BooleanField, Form, HiddenField -from wtforms.validators import DataRequired, Email, Length, URL, optional, \ +from wtforms.validators import DataRequired, Email, Length, URL, optional, EqualTo, \ ValidationError -from flask import url_for, Markup +from markupsafe import Markup +from flask import url_for +from flask import session as flask_session import pycountry -from arxiv import taxonomy +from arxiv.taxonomy import definitions from .util import MultiCheckboxField, OptGroupSelectField from .. import stateless_captcha -from arxiv_auth import legacy -from arxiv_auth.legacy import accounts -from arxiv_auth.legacy.exceptions import RegistrationFailed, \ +from arxiv.auth.legacy import sessions as legacy_sessions + +from arxiv.auth.legacy import accounts +from arxiv.auth.legacy.exceptions import RegistrationFailed, \ SessionCreationFailed, SessionDeletionFailed logger = logging.getLogger(__name__) @@ -44,8 +48,11 @@ def _login_classic(user: domain.User, auth: domain.Authorizations, ip: Optional[str]) -> Tuple[domain.Session, str]: try: - c_session = legacy.create(auth, ip, ip, user=user) - c_cookie = legacy.generate_cookie(c_session) + # c_session = legacy.create(auth, ip, ip, user=user) + # c_cookie = legacy.generate_cookie(c_session) + # no tracking cookie used + c_session = legacy_sessions.create(auth, ip, ip, '', user=user) + c_cookie = legacy_sessions.generate_cookie(c_session) logger.debug('Created classic session: %s', c_session.session_id) except SessionCreationFailed as ee: logger.debug('Could not create classic session: %s', ee) @@ -87,19 +94,69 @@ def register(method: str, params: MultiDict, captcha_secret: str, ip: str, form.configure_captcha(captcha_secret, ip) data = {'form': form, 'next_page': next_page} elif method == 'POST': - logger.debug('Registration form submitted') + logger.debug('Registration form advancing to step 2') form = RegistrationForm(params, next_page=next_page) data = {'form': form, 'next_page': next_page} form.configure_captcha(captcha_secret, ip) if not form.validate(): logger.debug('Registration form not valid') - return data, status.HTTP_400_BAD_REQUEST, {} + return data, status.BAD_REQUEST, {} + + logger.debug('Registration form is valid') + # password = form.password.data + + # Perform the actual registration. + + # user, auth = accounts.register(form.to_domain(), password, ip, ip) + + # try: + # user, auth = accounts.register(form.to_domain(), password, ip, ip) + # except RegistrationFailed as e: + # msg = 'Registration failed' + # raise InternalServerError(msg) from e # type: ignore + + # Log the user in. + # session, cookie = _login(user, auth, ip) + # c_session, c_cookie = _login_classic(user, auth, ip) + # data.update({ + # 'cookies': { + # 'session_cookie': (cookie, session.expires), + # 'classic_cookie': (c_cookie, c_session.expires) + # }, + # 'user_id': user.user_id + # }) + + return data, status.SEE_OTHER, {'Location': next_page} + return data, status.OK, {} + +def register2(method: str, params: MultiDict, ip: str, + next_page: str) -> ResponseData: + """Handle requests for the registration view step 2.""" + data: Dict[str, Any] + + logger.debug("session data", flask_session) + if method == 'GET': + form = ProfileForm(params) + data = {'form': form, 'next_page': next_page} + + elif method == 'POST': + logger.debug('Registration form submitted') + form = ProfileForm(params, next_page=next_page) + data = {'form': form, 'next_page': next_page} + # form.configure_captcha(captcha_secret, ip) + + if not form.validate(): + logger.debug('Registration form not valid') + return data, status.BAD_REQUEST, {} logger.debug('Registration form is valid') - password = form.password.data + password = flask_session['password'] # Perform the actual registration. + logger.debug("%s %s", str(password), str(form.forename.data)) + # user, auth = accounts.register(form.to_domain(), password, ip, ip) + try: user, auth = accounts.register(form.to_domain(), password, ip, ip) except RegistrationFailed as e: @@ -116,10 +173,11 @@ def register(method: str, params: MultiDict, captcha_secret: str, ip: str, }, 'user_id': user.user_id }) - return data, status.HTTP_303_SEE_OTHER, {'Location': next_page} - return data, status.HTTP_200_OK, {} + # next_page = next_page if good_next_page(next_page) else config.DEFAULT_LOGIN_REDIRECT_URL + return data, status.SEE_OTHER, {'Location': next_page} + return data, status.OK, {} def edit_profile(method: str, user_id: str, session: domain.Session, @@ -136,9 +194,9 @@ def edit_profile(method: str, user_id: str, session: domain.Session, try: if not form.validate(): - return data, status.HTTP_400_BAD_REQUEST, {} + return data, status.BAD_REQUEST, {} except ValueError: - return data, status.HTTP_400_BAD_REQUEST, {} + return data, status.BAD_REQUEST, {} if form.user_id.data != user_id: msg = 'User ID in request does not match' @@ -150,7 +208,7 @@ def edit_profile(method: str, user_id: str, session: domain.Session, except Exception as e: # pylint: disable=broad-except data['error'] = 'Could not save user profile; please try again' logger.error('Problem while editing profile', e) - return data, status.HTTP_500_INTERNAL_SERVER_ERROR, {} + return data, status.INTERNAL_SERVER_ERROR, {} # We need a new session, to update user's data. _logout(session.session_id) @@ -158,8 +216,8 @@ def edit_profile(method: str, user_id: str, session: domain.Session, data.update({'cookies': { 'session_cookie': (new_cookie, new_session.expires) }}) - return data, status.HTTP_303_SEE_OTHER, {} - return data, status.HTTP_200_OK, {} + return data, status.SEE_OTHER, {} + return data, status.OK, {} class ProfileForm(Form): @@ -169,17 +227,17 @@ class ProfileForm(Form): [(country.alpha_2, country.name) for country in pycountry.countries] RANKS = [('', '')] + domain.RANKS GROUPS = [ - (key, group['name']) - for key, group in taxonomy.definitions.GROUPS.items() - if not group.get('is_test', False) + (key, group.full_name) + for key, group in definitions.GROUPS.items() + if not group.is_test ] CATEGORIES = [ - (archive['name'], [ - (category_id, category['name']) - for category_id, category in taxonomy.CATEGORIES_ACTIVE.items() - if category['in_archive'] == archive_id + (archive.full_name, [ + (category_id, category.full_name) + for category_id, category in definitions.CATEGORIES_ACTIVE.items() + if category.in_archive == archive_id ]) - for archive_id, archive in taxonomy.ARCHIVES_ACTIVE.items() + for archive_id, archive in definitions.ARCHIVES_ACTIVE.items() ] """Categories grouped by archive.""" @@ -232,10 +290,13 @@ def from_domain(cls, user: domain.User) -> 'ProfileForm': def to_domain(self) -> domain.User: """Generate a :class:`.User` from this form's data.""" + logger.debug(self.default_category.data.split('.')) + # logger.debug(self.default_category.data) return domain.User( user_id=self.user_id.data if self.user_id.data else None, - username=self.username.data, - email=self.email.data, + # use flask session data for step 1 fields + username=flask_session['username'], + email=flask_session['email'], name=domain.UserFullName( forename=self.forename.data, surname=self.surname.data, @@ -246,9 +307,7 @@ def to_domain(self) -> domain.User: country=self.country.data, rank=int(self.status.data), # WTF can't handle int values. submission_groups=self.groups.data, - default_category=domain.Category( - *self.default_category.data.split('.') - ), + default_category=definitions.CATEGORIES[self.default_category.data], homepage_url=self.url.data, remember_me=self.remember_me.data ) @@ -276,7 +335,7 @@ class RegistrationForm(Form): password = PasswordField( 'Password', - validators=[Length(min=8, max=20), DataRequired()], + validators=[Length(min=8, max=20), DataRequired(), EqualTo('password2', message="Passwords must match.")], description="Please choose a password that is between 8 and 20" " characters in length. Longer passwords are more secure." " You may use alphanumeric characters, as well as" @@ -341,10 +400,10 @@ def validate_captcha_value(self, field: StringField) -> None: self.captcha_value.data = '' # Clear the field. raise ValidationError('Please try again') from e - def validate_password(self) -> None: - """Verify that the password is the same in both fields.""" - if self.password.data != self.password2.data: - raise ValidationError('Passwords must match') + # def validate_password(self) -> None: + # """Verify that the password is the same in both fields.""" + # if self.password.data != self.password2.data: + # raise ValidationError('Passwords must match') @classmethod def from_domain(cls, user: domain.User) -> 'RegistrationForm': diff --git a/admin_webapp/controllers/search.py b/admin_webapp/controllers/search.py new file mode 100644 index 0000000..15e768f --- /dev/null +++ b/admin_webapp/controllers/search.py @@ -0,0 +1,49 @@ +from flask import current_app, Response +from sqlalchemy import select, or_, func +from arxiv.db import session +from arxiv.db.models import TapirUser, TapirNickname + +from .util import Pagination + +""" +Basic search logic that does some loose similarity checking: + +""" +def general_search(search_string: str, per_page:int, page: int) -> Response: + # check if the string is numeric + if search_string.isdigit(): + # Check if unique user exists based on user ID + unique_user_id = session.query(TapirUser).filter(TapirUser.user_id==search_string).all() + if len(unique_user_id) == 1: + return dict(count=1, unique_id=search_string) + + # Check if unique user exists based on nickname + unique_user_nickname = session.query(TapirNickname).filter(TapirNickname.nickname==search_string).all() + if len(unique_user_nickname) == 1: + return dict(count=1, unique_id=unique_user_nickname[0].user_id) + + # General search logic + stmt = (select(TapirUser).join(TapirNickname) + .filter( + or_(TapirUser.user_id.like(f'%{search_string}%'), + TapirUser.first_name.like(f'%{search_string}%'), + TapirUser.last_name.like(f'%{search_string}%'), + TapirNickname.nickname.like(f'%{search_string}%') + )) + .limit(per_page).offset((page -1) * per_page)) + + count_stmt = (select(func.count(TapirUser.user_id)).where( + or_(TapirUser.user_id.like(f'%{search_string}%'), + TapirUser.first_name.like(f'%{search_string}%'), + TapirUser.last_name.like(f'%{search_string}%'), + ))) + + users = session.scalars(stmt) + count = session.execute(count_stmt).scalar_one() + + pagination = Pagination(query=None, page=page, per_page=per_page, total=count, items=None) + + return dict(pagination=pagination, count=count, users=users) + +def advanced_search(options): + return \ No newline at end of file diff --git a/admin_webapp/controllers/tapir_functions.py b/admin_webapp/controllers/tapir_functions.py new file mode 100644 index 0000000..23785eb --- /dev/null +++ b/admin_webapp/controllers/tapir_functions.py @@ -0,0 +1,51 @@ +from datetime import datetime + +from flask import current_app, Response, request +from sqlalchemy import select, func, insert + +from arxiv.db import session +from arxiv.db.models import TapirEmailTemplate + +""" +Tapir email templates +""" +def manage_email_templates() -> Response: + stmt = (select(TapirEmailTemplate)) + count_stmt = (select(func.count(TapirEmailTemplate.template_id))) + + email_templates = session.scalars(stmt) + count = session.execute(count_stmt).scalar_one() + + # return json.dumps(email_templates) + return dict(count=count, email_templates=email_templates) + +def email_template(template_id: int) -> Response: + stmt = (select(TapirEmailTemplate).where(TapirEmailTemplate.template_id == template_id)) + + template = session.scalar(stmt) + + return dict(template=template) + +def edit_email_template(template_id: int) -> Response: + stmt = (select(TapirEmailTemplate).where(TapirEmailTemplate.template_id == template_id)) + template = session.scalar(stmt) + + return dict(template=template) + +def create_email_template() -> Response: + if request.method == 'POST': + short_name = request.form.get('shortName') + long_name = request.form.get('longName') + template_data = request.form.get('templateData') + + # incomplete list + stmt = insert(TapirEmailTemplate).values( + short_name=short_name, + long_name=long_name, + update_date=int(datetime.now().astimezone(current_app.config['ARXIV_BUSINESS_TZ']).timestamp()), + workflow_status=2, + data=template_data + ) + session.execute(stmt) + session.commit() + return diff --git a/admin_webapp/controllers/tests/test_authentication.py b/admin_webapp/controllers/tests/test_authentication.py index 67c7734..da5834c 100644 --- a/admin_webapp/controllers/tests/test_authentication.py +++ b/admin_webapp/controllers/tests/test_authentication.py @@ -12,12 +12,13 @@ from werkzeug.exceptions import BadRequest from arxiv import status - -from arxiv_auth import domain -from arxiv_auth.legacy import exceptions, util, models +from arxiv.db import models, transaction +from arxiv.auth import domain +from arxiv.auth.legacy import exceptions, util from ...factory import create_web_app from ...controllers.authentication import login, logout, LoginForm +from ...config import Settings EASTERN = timezone('US/Eastern') @@ -40,25 +41,25 @@ def setUpClass(self): def setUp(self): self.ip_address = '10.1.2.3' self.environ_base = {'REMOTE_ADDR': self.ip_address} - self.app = create_web_app() - self.app.config['CLASSIC_COOKIE_NAME'] = 'foo_tapir_session' - self.app.config['AUTH_SESSION_COOKIE_NAME'] = 'baz_session' - self.app.config['AUTH_SESSION_COOKIE_SECURE'] = '0' - self.app.config['SESSION_DURATION'] = self.expiry - self.app.config['JWT_SECRET'] = self.secret - self.app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{self.db}' - self.app.config['CLASSIC_SESSION_HASH'] = 'xyz1234' - self.app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{self.db}' - self.app.config['REDIS_FAKE'] = True - self.app.config['SERVER_NAME'] = 'example.com' # to do urls in emails - + settings = { + 'CLASSIC_COOKIE_NAME':'foo_tapir_session', + 'AUTH_SESSION_COOKIE_NAME':'baz_session', + 'AUTH_SESSION_COOKIE_SECURE':False, + 'SESSION_DURATION':self.expiry, + 'JWT_SECRET':self.secret, + 'CLASSIC_DB_URI':f'sqlite:///{self.db}', + 'CLASSIC_SESSION_HASH':'xyz1234', + 'REDIS_FAKE':True, + 'SERVER_NAME':'example.com' + } + self.app = create_web_app(**settings) with self.app.app_context(): - util.drop_all() - util.create_all() + util.drop_all(self.app.engine) + util.create_all(self.app.engine) - with util.transaction() as session: + with transaction() as session: # We have a good old-fashioned user. - db_user = models.DBUser( + db_user = models.TapirUser( user_id=1, first_name='first', last_name='last', @@ -73,7 +74,7 @@ def setUp(self): flag_banned=0, tracking_cookie='foocookie', ) - db_nick = models.DBUserNickname( + db_nick = models.TapirNickname( nick_id=1, nickname='foouser', user_id=1, @@ -83,19 +84,19 @@ def setUp(self): policy=0, flag_primary=1 ) - db_demo = models.DBProfile( + db_demo = models.Demographic( user_id=1, country='US', affiliation='Cornell U.', url='http://example.com/bogus', - rank=2, + type=2, original_subject_classes='cs.OH', ) salt = b'fdoo' password = b'thepassword' hashed = hashlib.sha1(salt + b'-' + password).digest() encrypted = b64encode(salt + hashed) - db_password = models.DBUserPassword( + db_password = models.TapirUsersPassword( user_id=1, password_storage=2, password_enc=encrypted diff --git a/admin_webapp/controllers/tests/test_registration.py b/admin_webapp/controllers/tests/test_registration.py index 2c0d375..2ba46e6 100644 --- a/admin_webapp/controllers/tests/test_registration.py +++ b/admin_webapp/controllers/tests/test_registration.py @@ -11,8 +11,8 @@ from flask import Flask from arxiv import status - -from arxiv_auth.legacy import util, models +from arxiv.db import models, transaction +from arxiv.auth.legacy import util from admin_webapp.factory import create_web_app @@ -33,25 +33,26 @@ def setUpClass(self): def setUp(self): self.ip_address = '10.1.2.3' self.environ_base = {'REMOTE_ADDR': self.ip_address} - self.app = create_web_app() - self.app.config['CLASSIC_COOKIE_NAME'] = 'foo_tapir_session' - self.app.config['AUTH_SESSION_COOKIE_NAME'] = 'baz_session' - self.app.config['AUTH_SESSION_COOKIE_SECURE'] = '0' - self.app.config['SESSION_DURATION'] = self.expiry - self.app.config['JWT_SECRET'] = self.secret - self.app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{self.db}' - self.app.config['CLASSIC_SESSION_HASH'] = 'xyz1234' - self.app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{self.db}' - self.app.config['REDIS_FAKE'] = True - self.app.config['SERVER_NAME'] = 'example.com' # to do urls in emails + settings = { + 'CLASSIC_COOKIE_NAME':'foo_tapir_session', + 'AUTH_SESSION_COOKIE_NAME':'baz_session', + 'AUTH_SESSION_COOKIE_SECURE':False, + 'SESSION_DURATION':self.expiry, + 'JWT_SECRET':self.secret, + 'CLASSIC_DB_URI':f'sqlite:///{self.db}', + 'CLASSIC_SESSION_HASH':'xyz1234', + 'REDIS_FAKE':True, + 'SERVER_NAME':'example.com' + } + self.app = create_web_app(**settings) with self.app.app_context(): - util.drop_all() - util.create_all() + util.drop_all(self.app.engine) + util.create_all(self.app.engine) - with util.transaction() as session: + with transaction() as session: # We have a good old-fashioned user. - db_user = models.DBUser( + db_user = models.TapirUser( user_id=1, first_name='first', last_name='last', @@ -66,7 +67,7 @@ def setUp(self): flag_banned=0, tracking_cookie='foocookie', ) - db_nick = models.DBUserNickname( + db_nick = models.TapirNickname( nick_id=1, nickname='foouser', user_id=1, @@ -76,19 +77,19 @@ def setUp(self): policy=0, flag_primary=1 ) - db_demo = models.DBProfile( + db_demo = models.Demographic( user_id=1, country='US', affiliation='Cornell U.', url='http://example.com/bogus', - rank=2, + type=2, original_subject_classes='cs.OH', ) salt = b'fdoo' password = b'thepassword' hashed = hashlib.sha1(salt + b'-' + password).digest() encrypted = b64encode(salt + hashed) - db_password = models.DBUserPassword( + db_password = models.TapirUsersPassword( user_id=1, password_storage=2, password_enc=encrypted @@ -100,7 +101,7 @@ def setUp(self): def tearDown(self): with self.app.app_context(): - util.drop_all() + util.drop_all(self.app.engine) try: os.remove(self.db) except FileNotFoundError: @@ -403,7 +404,7 @@ def test_post_minimum(self, users, create, invalidate): profile_data['affiliation']) self.assertEqual(user.profile.country, profile_data['country']) - self.assertEqual(user.profile.rank, + self.assertEqual(user.profile.type, int(profile_data['status'])) self.assertEqual(user.profile.default_category.archive, 'astro-ph') diff --git a/admin_webapp/controllers/users.py b/admin_webapp/controllers/users.py new file mode 100644 index 0000000..3aabf36 --- /dev/null +++ b/admin_webapp/controllers/users.py @@ -0,0 +1,337 @@ +"""arXiv paper users controllers.""" +from collections import defaultdict +from datetime import datetime, timedelta +import logging +from admin_webapp.routes import endorsement + +from flask import Blueprint, render_template, request, \ + make_response, current_app, Response, abort + +from sqlalchemy import select, func, case, text, insert, update, desc +from sqlalchemy.dialects.mysql import JSON +from sqlalchemy.orm import joinedload, selectinload, aliased +from arxiv.base import logging + +from arxiv.auth.auth.decorators import scoped +from arxiv.db import session +from arxiv.db.models import ( + TapirUser, Document, ShowEmailRequest, Demographic, TapirNickname, + TapirAdminAudit, TapirSession, Endorsement, t_arXiv_moderators) + + +from .util import Pagination + +logger = logging.getLogger(__name__) + +# blueprint = Blueprint('ownership', __name__, url_prefix='/ownership') +""" +Get user profile +""" +def user_profile(user_id:int) -> Response: + stmt = (select(TapirUser) + .where( + TapirUser.user_id == user_id + )) + logger.debug(stmt) + user = session.scalar(stmt) + # TODO: optimize this so we can join with the Tapir Users rather than separate query? + demographics_stmt = (select(Demographic) + .where( + Demographic.user_id == user_id + )) + demographics = session.scalar(demographics_stmt) + + if not user or not demographics: + abort(404) + + + # do a join here with documents + + # maybe do a join with sessions too? + logs = session.query(TapirAdminAudit, Document, TapirSession).filter(TapirAdminAudit.affected_user == user_id).order_by(desc(TapirAdminAudit.log_date)) + logs = logs.outerjoin(Document, (TapirAdminAudit.data == Document.document_id) & (TapirAdminAudit.action.in_(['add-paper-owner','add-paper-owner-2']))) + logs = logs.outerjoin(TapirSession, (TapirAdminAudit.data == TapirSession.session_id) & (TapirAdminAudit.action.in_(['become-user',]))).all() + + tapir_sessions = session.query(TapirSession).filter(TapirSession.user_id == user_id).order_by(desc(TapirSession.start_time)).all() + + email_request_count = session.query(func.count(func.distinct(ShowEmailRequest.document_id))).filter(ShowEmailRequest.user_id == 123).scalar() + + endorsement_stmt = (select(Endorsement).where(Endorsement.endorsee_id==user_id)) + endorsements = session.scalars(endorsement_stmt) + + has_endorsed_sql = "select endorsement_id,archive,endorsee_id,nickname,archive,subject_class,arXiv_endorsements.flag_valid,type,point_value from arXiv_endorsements left join tapir_nicknames on (endorsee_id=user_id AND flag_primary=1) where endorser_id=:user_id order by archive,subject_class" + has_endorsed = session.execute(has_endorsed_sql, {"user_id": user_id}) + + papers_sql = "SELECT d.document_id,d.paper_id,m.title AS metadata_title,d.authors,d.submitter_id,flag_author,valid FROM arXiv_documents d JOIN arXiv_paper_owners po ON po.document_id=d.document_id JOIN arXiv_metadata m ON m.document_id=d.document_id WHERE po.user_id=:user_id AND m.is_current=1 ORDER BY dated DESC" + papers_sql_len = f"SELECT COUNT(*) FROM ({papers_sql}) as subquery" + papers = session.execute(papers_sql, {"user_id": user_id}) + papers_len = session.execute(papers_sql_len, {"user_id": user_id}) + data = dict(user=user, + demographics=demographics, + logs=logs, + sessions=tapir_sessions, + email_request_count=email_request_count, + endorsements=endorsements, + has_endorsed=has_endorsed, + papers=papers, + papers_len=papers_len.fetchone()[0]) + + return data + + +def administrator_listing(per_page:int, page: int) -> dict: + report_stmt = (select(TapirUser) + # TODO: do I need a joinedload to prevent N+1 queries + # .options(joinedload(TapirUser.tapir_nicknames)) + # .join(TapirNickname, TapirUser.tapir_nicknames, isouter=True) + .filter(TapirUser.policy_class == 1) # admin policy class + .limit(per_page).offset((page -1) * per_page)) + + count_stmt = (select(func.count(TapirUser.user_id)) + .where(TapirUser.policy_class == 1)) + + # if workflow_status in ('accepted', 'rejected'): + # window = datetime.now() - timedelta(days=days_back) + # report_stmt = report_stmt.join(OwnershipRequestsAudit).filter( OwnershipRequestsAudit.date > window) + # count_stmt = count_stmt.join(OwnershipRequestsAudit).filter(OwnershipRequestsAudit.date > window) + + users = session.scalars(report_stmt) + count = session.execute(count_stmt).scalar_one() + pagination = Pagination(query=None, page=page, per_page=per_page, total=count, items=None) + # why does this prevent print out ? + + return dict(pagination=pagination, count=count, users=users) + +def administrator_edit_sys_listing(per_page:int, page: int) -> dict: + report_stmt = (select(TapirUser) + # TODO: do I need a joinedload to prevent N+1 queries + # .options(joinedload(TapirUser.tapir_nicknames)) + .filter(TapirUser.policy_class == 1) # admin policy class + .filter(TapirUser.flag_edit_system == 1) + .limit(per_page).offset((page -1) * per_page)) + + count_stmt = (select(func.count(TapirUser.user_id)) + .where(TapirUser.flag_edit_system == 1) + .where(TapirUser.policy_class == 1)) + + users = session.scalars(report_stmt) + count = session.execute(count_stmt).scalar_one() + pagination = Pagination(query=None, page=page, per_page=per_page, total=count, items=None) + return dict(pagination=pagination, count=count, users=users) + +def suspect_listing(per_page:int, page: int) -> dict: + report_stmt = select(TapirUser, func.count(TapirSession.session_id).label('session_count'))\ + .join(Demographic, Demographic.user_id==TapirUser.user_id)\ + .filter(Demographic.flag_suspect == "1")\ + .join(TapirSession, TapirUser.user_id==TapirSession.user_id)\ + .group_by(TapirUser.user_id)\ + .limit(per_page).offset((page -1) * per_page) + # .subquery() + + # report_stmt = select(report_stmt.c.user_id, report_stmt.c.email, func.count())\ + # .join(TapirSession, report_stmt.c.user_id==TapirSession.user_id)\ + # .group_by(report_stmt.c.user_id)\ + # .limit(per_page).offset((page -1) * per_page) + test_stmt = (select(TapirUser).limit(10)) + + users = session.execute( + select( + TapirUser.user_id, + TapirUser.first_name, + TapirNickname.nickname, + TapirUser.last_name, + TapirUser.joined_date, + TapirUser.email, + TapirUser.flag_email_verified, + func.count(TapirSession.session_id).label('session_count') + ) + .join(TapirNickname, TapirNickname.user_id == TapirUser.user_id) + .join(Demographic, Demographic.user_id == TapirUser.user_id) + .join(TapirSession, TapirSession.user_id == TapirUser.user_id) + .filter(Demographic.flag_suspect == 1) + .group_by( + TapirUser.user_id, + TapirUser.first_name, + TapirNickname.nickname, + TapirUser.last_name, + TapirUser.joined_date, + TapirUser.email, + TapirUser.flag_email_verified + ) + .limit(per_page) + .offset((page - 1) * per_page) + ).all() + + test=session.scalars(test_stmt) + suspects = select(TapirUser) \ + .join(Demographic) \ + .filter(Demographic.flag_suspect == "1") \ + .subquery() + + count = select(func.count()).select_from(suspects) + count = session.scalar(count) + + # users = session.scalars(report_stmt) + # users = session.scalars(report_stmt) + logger.debug(users[0].session_count) + pagination = Pagination(query=None, page=page, per_page=per_page, total=count, items=None) + + return dict(pagination=pagination, count=count, users=users, test=test) + + +def moderator_listing() -> dict: + count_stmt = select(func.count(func.distinct(t_arXiv_moderators.c.user_id))) + + # mods = session.scalars(report_stmt) + + user_alias = aliased(TapirUser, name='mod_user') + mods = ( + session.query( + t_arXiv_moderators.c.user_id, + func.aggregate_strings( + func.concat( + t_arXiv_moderators.c.archive, + case((t_arXiv_moderators.c.subject_class != '', '.'), else_=''), + t_arXiv_moderators.c.subject_class + ), + ', ' + ).label('archive_subject_list'), + user_alias, + ) + .join(user_alias, t_arXiv_moderators.c.user_id == user_alias.user_id) + # .join(TapirNickname, Moderators.user_id == TapirNickname.user_id) + .group_by(t_arXiv_moderators.c.user_id) + .order_by(user_alias.last_name) # order by nickname? + .all() + ) + + count = session.execute(count_stmt).scalar_one() + + return dict(count=count, mods=mods) + +# TODO: optimize this once a relationship between TapirUser and Moderators model is established +def moderator_by_category_listing() -> dict: + count_stmt = select(func.count(func.distinct(t_arXiv_moderators.c.user_id))) + + user_alias = aliased(TapirUser, name='mod_user') + mods = ( + session.query( + t_arXiv_moderators.c.user_id, + func.aggregate_strings( + func.concat( + t_arXiv_moderators.c.archive, + case((t_arXiv_moderators.c.subject_class != '', '.'), else_=''), + t_arXiv_moderators.c.subject_class + ), + # order_by=(t_arXiv_moderators.c.archive, t_arXiv_moderators.c.subject_class), + ', ' + ).label('archive_subject_list'), + user_alias, + ) + .join(user_alias, t_arXiv_moderators.c.user_id == user_alias.user_id) + .group_by(t_arXiv_moderators.c.user_id) + .all() + ) + + count = session.execute(count_stmt).scalar_one() + mods_map = defaultdict(list) + for mod in mods: + for pair in mod.archive_subject_list.split(','): + mods_map[pair].append(mod.mod_user) + pairs = sorted(list(mods_map.items()), key=lambda x: x[0]) + print (f"PAIRS: {pairs}") + return dict(count=count, pairs=pairs) + +def add_to_blocked(): + if request.method == 'POST': + new_pattern = request.form.get('') + + return Response(status=204) + +def add_to_approved(): + if request.method == 'POST': + new_pattern = request.form.get('') + + return Response(status=204) + +def non_academic_email_listing(): + blocked_users_all_sql = "create temporary table blocked_users select user_id,email,pattern as black_pattern,joined_date,first_name,last_name,suffix_name from tapir_users,arXiv_black_email where joined_date>UNIX_TIMESTAMP(DATE_SUB(CURDATE(),INTERVAL 30 MONTH)) and email like pattern" + session.execute(blocked_users_all_sql) + blocked_users_sql = "select user_id,email,joined_date,black_pattern,first_name,last_name,suffix_name from blocked_users left join arXiv_white_email on email like pattern where pattern is null group by user_id, email, joined_date, black_pattern, first_name, last_name, suffix_name order by joined_date desc" + blocked_users_sql_len = f"SELECT COUNT(*) FROM ({blocked_users_sql}) as subquery" + + blocked_users = session.execute(blocked_users_sql) + count = session.execute(blocked_users_sql_len) + return dict(users=blocked_users, count=count) + +def flip_email_verified_flag(): + if request.method == 'POST': + # do the SQL update here + verified = request.form.get('emailVerified') + user_id = request.form.get('user_id') + if verified == 'on': + # update the object + session.execute(update(TapirUser).where( + TapirUser.user_id==user_id + ).values(flag_email_verified = 1)) + # update the activity log + elif not verified: + session.execute(update(TapirUser).where( + TapirUser.user_id==user_id + ).values(flag_email_verified = 0)) + session.commit() + return Response(status=204) + +def flip_bouncing_flag(): + if request.method == 'POST': + bouncing = request.form.get('bouncing') + user_id = request.form.get('user_id') + if bouncing == 'on': + session.execute(update(TapirUser).where(TapirUser.user_id==user_id).values(email_bouncing = 1)) + + elif not bouncing: + session.execute(update(TapirUser).where(TapirUser.user_id==user_id).values(email_bouncing = 0)) + session.commit() + return Response(status=204) + +def flip_edit_users_flag(): + if request.method == 'POST': + edit_users = request.form.get('editUsers') + user_id = request.form.get('user_id') + if edit_users == 'on': + session.execute(update(TapirUser).where(TapirUser.user_id==user_id).values(flag_edit_users = 1)) + + elif not edit_users: + session.execute(update(TapirUser).where(TapirUser.user_id==user_id).values(flag_edit_users = 0)) + session.commit() + return Response(status=204) + +def flip_edit_system_flag(): + if request.method == 'POST': + edit_system = request.form.get('editSystem') + user_id = request.form.get('user_id') + if edit_system == 'on': + session.execute(update(TapirUser).where(TapirUser.user_id==user_id).values(flag_edit_system = 1)) + elif not edit_system: + session.execute(update(TapirUser).where(TapirUser.user_id==user_id).values(flag_edit_system = 0)) + session.commit() + return Response(status=204) + +def flip_proxy_flag(): + if request.method == 'POST': + print('post') + + return Response(status=204) + +# wip flags +def flip_suspect_flag(): + if request.method == 'POST': + print('post') + + return Response(status=204) + +def flip_next_flag(): + if request.method == 'POST': + print('post') + + return Response(status=204) diff --git a/admin_webapp/controllers/util.py b/admin_webapp/controllers/util.py index b4b0c64..cc68c60 100644 --- a/admin_webapp/controllers/util.py +++ b/admin_webapp/controllers/util.py @@ -1,5 +1,6 @@ """Helpers for :mod:`accounts.controllers`.""" from typing import Any +from math import ceil from wtforms.widgets import ListWidget, CheckboxInput, Select, \ html_params @@ -63,3 +64,107 @@ def pre_validate(self, form: Form) -> None: if value == self.data: return raise ValueError(self.gettext('Not a valid choice')) + + +# This code is stolen from flask_sqlalchemy +class Pagination(object): + """Internal helper class returned by :meth:`BaseQuery.paginate`. You + can also construct it from any other SQLAlchemy query object if you are + working with other libraries. Additionally it is possible to pass `None` + as query object in which case the :meth:`prev` and :meth:`next` will + no longer work. + """ + + def __init__(self, query, page, per_page, total, items): + #: the unlimited query object that was used to create this + #: pagination object. + self.query = query + #: the current page number (1 indexed) + self.page = page + #: the number of items to be displayed on a page. + self.per_page = per_page + #: the total number of items matching the query + self.total = total + #: the items for the current page + self.items = items + + @property + def pages(self): + """The total number of pages""" + if self.per_page == 0: + pages = 0 + else: + pages = int(ceil(self.total / float(self.per_page))) + return pages + + def prev(self, error_out=False): + """Returns a :class:`Pagination` object for the previous page.""" + assert self.query is not None, 'a query object is required ' \ + 'for this method to work' + return self.query.paginate(self.page - 1, self.per_page, error_out) + + @property + def prev_num(self): + """Number of the previous page.""" + if not self.has_prev: + return None + return self.page - 1 + + @property + def has_prev(self): + """True if a previous page exists""" + return self.page > 1 + + def next(self, error_out=False): + """Returns a :class:`Pagination` object for the next page.""" + assert self.query is not None, 'a query object is required ' \ + 'for this method to work' + return self.query.paginate(self.page + 1, self.per_page, error_out) + + @property + def has_next(self): + """True if a next page exists.""" + return self.page < self.pages + + @property + def next_num(self): + """Number of the next page""" + if not self.has_next: + return None + return self.page + 1 + + def iter_pages(self, left_edge=2, left_current=2, + right_current=5, right_edge=2): + """Iterates over the page numbers in the pagination. The four + parameters control the thresholds how many numbers should be produced + from the sides. Skipped page numbers are represented as `None`. + This is how you could render such a pagination in the templates: + + .. sourcecode:: html+jinja + + {% macro render_pagination(pagination, endpoint) %} + + {% endmacro %} + """ + last = 0 + for num in range(1, self.pages + 1): + if num <= left_edge or \ + (num > self.page - left_current - 1 and + num < self.page + right_current) or \ + num > self.pages - right_edge: + if last + 1 != num: + yield None + yield num + last = num diff --git a/admin_webapp/extensions.py b/admin_webapp/extensions.py index c88006e..aeccf64 100644 --- a/admin_webapp/extensions.py +++ b/admin_webapp/extensions.py @@ -9,5 +9,5 @@ def get_db(app:Flask) -> SQLAlchemy: return app.extensions['sqlalchemy'].db def get_csrf(app:Flask) -> CSRFProtect: - """Gets CSRF for the app""" + """Gets CSRF for the app.""" return app.extensions['csrf'] diff --git a/admin_webapp/factory.py b/admin_webapp/factory.py index 0b548b7..87b28cf 100644 --- a/admin_webapp/factory.py +++ b/admin_webapp/factory.py @@ -1,31 +1,37 @@ """Application factory for admin_webapp.""" import logging +import os + +from pythonjsonlogger import jsonlogger from flask import Flask -from flask_s3 import FlaskS3 +from flask_session import Session from flask_bootstrap import Bootstrap5 from flask_wtf.csrf import CSRFProtect from arxiv.base import Base from arxiv.base.middleware import wrap - -from arxiv_auth import auth -from arxiv_auth.auth.middleware import AuthMiddleware -from arxiv_auth.auth.sessions import SessionStore -from arxiv_auth.legacy.util import init_app as legacy_init_app -from arxiv_auth.legacy.util import create_all as legacy_create_all - -from flask_sqlalchemy import SQLAlchemy - -import arxiv_db - +from arxiv.db.models import configure_db +from arxiv.auth import auth +from arxiv.auth.auth.middleware import AuthMiddleware +from arxiv.auth.legacy.util import create_all as legacy_create_all +from arxiv.auth.auth.sessions.store import SessionStore + +from . import filters +from .config import Settings from .routes import ui, ownership, endorsement, user, paper -s3 = FlaskS3() -logger = logging.getLogger(__name__) +def setup_logger(): + logHandler = logging.StreamHandler() + formatter = jsonlogger.JsonFormatter('%(asctime)s %(levelname)s %(name)s %(message)s', + rename_fields={'levelname': 'level', 'asctime': 'timestamp'}) + logHandler.setFormatter(formatter) + logger = logging.getLogger() + logger.addHandler(logHandler) + logger.setLevel(logging.DEBUG) csrf = CSRFProtect() @@ -47,11 +53,22 @@ def change_loglevel(pkg:str, level): for handler in logger_x.handlers: handler.setLevel(level) -def create_web_app() -> Flask: +def create_web_app(**kwargs) -> Flask: """Initialize and configure the admin_webapp application.""" + setup_logger() + logger = logging.getLogger(__name__) app = Flask('admin_webapp') - app.config.from_pyfile('config.py') + settings = Settings(**kwargs) + app.config.from_object(settings) + app.engine, _ = configure_db(settings) + session_lifetime = app.config['PERMANENT_SESSION_LIFETIME'] + + logger.info(f"Session Lifetime: {session_lifetime} seconds") + # Configure Flask session (use FakeRedis for dev purposes) + app.config['SESSION_TYPE'] = 'redis' + app.config['SESSION_REDIS'] = SessionStore.get_session(app).r + Session(app) Bootstrap5(app) # change_loglevel('arxiv_auth.legacy.authenticate', 'DEBUG') @@ -74,11 +91,6 @@ def create_web_app() -> Flask: # SERVER_NAME. app.config['SERVER_NAME'] = None - SessionStore.init_app(app) - legacy_init_app(app) - - SQLAlchemy(app, metadata=arxiv_db.Base.metadata) - app.register_blueprint(ui.blueprint) app.register_blueprint(ownership.blueprint) app.register_blueprint(endorsement.blueprint) @@ -87,7 +99,6 @@ def create_web_app() -> Flask: Base(app) auth.Auth(app) - s3.init_app(app) csrf.init_app(app) [csrf.exempt(view.strip()) @@ -95,19 +106,17 @@ def create_web_app() -> Flask: wrap(app, [AuthMiddleware]) - settup_warnings(app) + setup_warnings(app) + + app.jinja_env.filters['unix_to_datetime'] = filters.unix_to_datetime if app.config['CREATE_DB']: - with app.app_context(): - print("About to create the legacy DB") - legacy_create_all() + legacy_create_all(app.engine) return app -def settup_warnings(app): - if not app.config['SQLALCHEMY_DATABASE_URI'] and not app.config['DEBUG']: - logger.error("SQLALCHEMY_DATABASE_URI is not set!") - - if not app.config['WTF_CSRF_ENABLED'] and not(app.config['FLASK_DEBUG'] or app.config['DEBUG']): +def setup_warnings(app): + logger = logging.getLogger(__name__) + if not app.config.get('WTF_CSRF_ENABLED'): logger.warning("CSRF protection is DISABLED, Do not disable CSRF in production") diff --git a/admin_webapp/filters.py b/admin_webapp/filters.py new file mode 100644 index 0000000..fdfa879 --- /dev/null +++ b/admin_webapp/filters.py @@ -0,0 +1,5 @@ +"""Jinja2 templating filters""" +from datetime import datetime + +def unix_to_datetime(unix_time): + return datetime.utcfromtimestamp(unix_time) diff --git a/admin_webapp/routes/__init__.py b/admin_webapp/routes/__init__.py index 1bd0cad..601864d 100644 --- a/admin_webapp/routes/__init__.py +++ b/admin_webapp/routes/__init__.py @@ -1 +1,25 @@ """Contains route information.""" +from typing import Dict + +from sqlalchemy.sql import select, exists + +from arxiv.auth.auth.decorators import scoped +from arxiv.db import session as db_session +from arxiv.db.models import TapirUser + +def _is_admin (session: Dict, *args, **kwargs) -> bool: + try: + uid = session.user.user_id + except: + return False + return db_session.scalar( + select(TapirUser) + .filter(TapirUser.flag_edit_users == 1) + .filter(TapirUser.user_id == uid)) is not None + +admin_scoped = scoped( + required=None, + resource=None, + authorizer=_is_admin, + unauthorized=None +) \ No newline at end of file diff --git a/admin_webapp/routes/endorsement.py b/admin_webapp/routes/endorsement.py index 66eba22..445aaff 100644 --- a/admin_webapp/routes/endorsement.py +++ b/admin_webapp/routes/endorsement.py @@ -1,11 +1,203 @@ """arXiv endorsement routes.""" -from flask import Blueprint, render_template, Response +from datetime import datetime, timedelta +from sqlalchemy import select, func +from sqlalchemy.orm import joinedload + +from flask import Blueprint, render_template, Response, request, current_app, abort, redirect, url_for, make_response + +from wtforms import SelectField, BooleanField, StringField, validators +from flask_wtf import FlaskForm + +from . import admin_scoped +# need to refactor this back into a controller +from arxiv.db.models import EndorsementRequest, Endorsement, TapirUser +from arxiv.db import session +from arxiv.base.alerts import flash_failure + +from admin_webapp.controllers.endorsement import endorsement_listing # multiple implementations of listing here blueprint = Blueprint('endorsement', __name__, url_prefix='/endorsement') +@blueprint.route('/all', methods=['GET']) +@admin_scoped +def endorsements() -> Response: + """ + Show administrators view + """ + args = request.args + per_page = args.get('per_page', default=12, type=int) + page = args.get('page', default=1, type=int) + + data = endorsement_listing(per_page, page) + data['title'] = "Endorsement" + return render_template('endorsement/list.html', **data) -@blueprint.route('/request', methods=['GET']) -def request_detail() -> Response: - """Display a endorsement.""" +@blueprint.route('/request/', methods=['GET']) +@admin_scoped +def request_detail(endorsement_req_id:int) -> Response: + """Display a single request for endorsement.""" + stmt = (select(EndorsementRequest) + .options(joinedload(EndorsementRequest.endorsee).joinedload(TapirUser.tapir_nicknames), + joinedload(EndorsementRequest.endorsement).joinedload(Endorsement.endorser).joinedload(TapirUser.tapir_nicknames), + joinedload(EndorsementRequest.audit)) + .filter(EndorsementRequest.request_id == endorsement_req_id) + ) + endo_req = session.execute(stmt).scalar() or abort(404) + return render_template('endorsement/request_detail.html', + **dict(endorsement_req_id=endorsement_req_id, + endo_req=endo_req, + )) return render_template('endorsement/display.html') + +@blueprint.route('/request//flip_valid', methods=['POST']) +@admin_scoped +def flip_valid(endorsement_req_id:int) -> Response: + """Flip an endorsement_req valid column.""" + stmt = (select(EndorsementRequest) + .options(joinedload(EndorsementRequest.endorsement)) + .filter(EndorsementRequest.request_id == endorsement_req_id)) + endo_req = session.execute(stmt).scalar() or abort(404) + endo_req.endorsement.flag_valid = not bool(endo_req.endorsement.flag_valid) + session.commit() + return redirect(url_for('endorsement.request_detail', endorsement_req_id=endorsement_req_id)) + +@blueprint.route('/request//flip_score', methods=['POST']) +@admin_scoped +def flip_score(endorsement_req_id:int) -> Response: + """Flip an endorsement_req score.""" + stmt = (select(EndorsementRequest) + .options(joinedload(EndorsementRequest.endorsement)) + .filter(EndorsementRequest.request_id == endorsement_req_id)) + endo_req = session.execute(stmt).scalar() or abort(404) + if endo_req.endorsement.point_value > 0: + endo_req.endorsement.point_value = 0 + else: + endo_req.endorsement.point_value = 10 + + session.commit() + return redirect(url_for('endorsement.request_detail', endorsement_req_id=endorsement_req_id)) + +@blueprint.route('/', methods=['GET']) +@admin_scoped +def detail(endorsement_id: int) -> Response: + """Display a single endorsement.""" + abort(500) + +@blueprint.route('/requests/today', methods=['GET']) +@admin_scoped +def today() -> Response: + """Reports today's endorsement requests.""" + args = request.args + per_page = args.get('per_page', default=12, type=int) + page = args.get('page', default=1, type=int) + flagged = args.get('flagged', default=0, type=int) + _check_report_args(per_page, page, 0, flagged) + data = endorsement_listing('today', per_page, page, 0, flagged) + data['title'] = f"Today's {'Flagged ' if flagged else ''}Endorsement Requests" + return render_template('endorsement/list.html', **data) + +def _check_report_args(per_page, page, days_back, flagged): + if per_page > 1000: + abort(400) + if page > 10000: + abort(400) + # will not show data older than 10 years + if days_back > 365 * 10: + abort(400) + if flagged not in [1, 0]: + abort(400) + +@blueprint.route('/requests/last_week', methods=['GET']) +@admin_scoped +def last_week() -> Response: + """Reports last 7 days endorsement requests.""" + args = request.args + per_page = args.get('per_page', default=12, type=int) + page = args.get('page', default=1, type=int) + flagged = args.get('flagged', default=0, type=int) + days_back = args.get('days_back', default=7, type=int) + _check_report_args(per_page, page, days_back, flagged) + data = endorsement_listing('last_week', per_page, page, days_back, flagged) + data['title'] = f"Endorsement {'Flagged ' if flagged else ''}Requests Last {days_back} Days" + return render_template('endorsement/list.html', **data) + + +@blueprint.route('/requests/negative', methods=['GET']) +@admin_scoped +def negative() -> Response: + """Reports non-positive scored endorsement requests for last 7 days.""" + args = request.args + per_page = args.get('per_page', default=12, type=int) + page = args.get('page', default=1, type=int) + days_back = args.get('days_back', default=7, type=int) + _check_report_args(per_page, page, days_back, 0) + data = endorsement_listing('negative', per_page, page, days_back, False, not_positive=True) + data['title'] = "Negative Endorsement Requests" + return render_template('endorsement/list.html', **data) + +""" +New feature for approved and blocked user list +""" +@blueprint.route('/modify', methods=['GET']) +@admin_scoped +def modify_form() -> Response: + """Modify lists""" + return render_template('endorsement/modify.html') + +@blueprint.route('/endorse', methods=['GET', 'POST']) +@admin_scoped +def endorse() -> Response: + """Endorse page.""" + + # TODO check veto_status == no-endorse and mesage similar to no-endorse-screen.php + + if request.method == 'GET': + return render_template('endorsement/endorse.html') + # elif request.method == 'POST' and not request.form.get('x',None): + # flash_failure("You must enger a non-blank endorsement code.") + # return make_response(render_template('endorsement/endorse.html'), 400) + + form = EndorseStage2Form() + + endo_code = request.form.get('x') + stmt = (select(EndorsementRequest) + .limit(1) + #.filter(EndorsementRequest.secret == endo_code) + ) + endoreq = session.scalar(stmt) + + if not endoreq: + flash_failure("The endorsement codes is not valid. It has never been issued.") + return make_response(render_template('endorsement/endorse.html'), 400) + + if not endoreq.flag_valid: + flash_failure("The endorsement code is not valid. It has either expired or been deactivated.") + return make_response(render_template('endorsement/endorse.html'), 400) + + category = f"{endoreq.archive}.{endoreq.subject_class}" if endoreq.subject_class else endoreq.archive + + if endoreq.endorsee_id == request.auth.user.user_id: + return make_response(render_template('endorsement/no-self-endorse.html'), 400) + + + if request.method == 'POST' and request.form.get('choice', None): + form.validate() + # TODO Do save? + + + data=dict(endorsee=endoreq.endorsee, + endorsement=endoreq.endorsement, + form = form, + ) + + return render_template('endorsement/endorse-stage2.html', **data) + +class EndorseStage2Form(FlaskForm): + """Form for stage 2 of endorse.""" + choice = SelectField('choice', + [validators.InputRequired(), validators.AnyOf(['do','do not'])], + choices=[(None,'-- choose --'),('do','do'),('do not','do not')]) + knows_personally = BooleanField('knows_personally') + seen_paper = BooleanField('seen_paper') + comment = StringField('comment') \ No newline at end of file diff --git a/admin_webapp/routes/ownership.py b/admin_webapp/routes/ownership.py index 359f24c..b9f694f 100644 --- a/admin_webapp/routes/ownership.py +++ b/admin_webapp/routes/ownership.py @@ -6,11 +6,13 @@ from admin_webapp.controllers.ownership import ownership_detail, \ ownership_listing, ownership_post +from . import admin_scoped blueprint = Blueprint('ownership', __name__, url_prefix='/ownership') @blueprint.route('/', methods=['GET', 'POST']) +@admin_scoped def display(ownership_id:int) -> Response: if request.method == 'GET': return render_template('ownership/display.html',**ownership_detail(ownership_id, None)) @@ -19,6 +21,7 @@ def display(ownership_id:int) -> Response: @blueprint.route('/pending', methods=['GET']) +@admin_scoped def pending() -> Response: """Pending ownership requests.""" args = request.args @@ -31,6 +34,7 @@ def pending() -> Response: @blueprint.route('/accepted', methods=['GET']) +@admin_scoped def accepted() -> Response: """Accepted ownership requests.""" args = request.args @@ -45,6 +49,7 @@ def accepted() -> Response: @blueprint.route('/rejected', methods=['GET']) +@admin_scoped def rejected() -> Response: """Rejected ownership reqeusts.""" args = request.args @@ -56,3 +61,18 @@ def rejected() -> Response: data['title'] = f"Ownership Reqeusts: Rejected last {days_back} days" return render_template('ownership/list.html', **data) + +# @scoped() +@blueprint.route('/need-paper-password', methods=['GET','POST']) +@admin_scoped +def need_papper_password() -> Response: + """User claims ownership of a paper using submitter provided password.""" + form = PaperPasswordForm() + if request.method == 'GET': + return render_template('ownership/need_paper_password.html', **dict(form=form)) + elif request.method == 'POST': + data=paper_password_post(form, request) + if data['success']: + return render_template('ownership/need_paper_password.html', **data) + else: + return make_response(render_template('ownership/need_paper_password.html', **data), 400) \ No newline at end of file diff --git a/admin_webapp/routes/paper.py b/admin_webapp/routes/paper.py index f16fa53..1d8c4aa 100644 --- a/admin_webapp/routes/paper.py +++ b/admin_webapp/routes/paper.py @@ -1,11 +1,15 @@ """arXiv paper display routes.""" from flask import Blueprint, render_template, Response +from admin_webapp.controllers.document import paper_detail + +from . import admin_scoped blueprint = Blueprint('paper', __name__, url_prefix='/paper') @blueprint.route('/detail/', methods=['GET']) +@admin_scoped def detail(paper_id:str) -> Response: """Display a paper.""" - return render_template('paper/detail.html') + return render_template('paper/detail.html', **paper_detail(paper_id)) diff --git a/admin_webapp/routes/tapir_session.py b/admin_webapp/routes/tapir_session.py new file mode 100644 index 0000000..0f316c0 --- /dev/null +++ b/admin_webapp/routes/tapir_session.py @@ -0,0 +1,14 @@ +"""tapir session routes.""" + +from flask import Blueprint, render_template, Response + +from . import admin_scoped + +blueprint = Blueprint('session', __name__, url_prefix='/session') + + +@blueprint.route('/', methods=['GET']) +@admin_scoped +def detail(session_id: int) -> Response: + """Display a session.""" + return render_template('session_display.html') \ No newline at end of file diff --git a/admin_webapp/routes/ui.py b/admin_webapp/routes/ui.py index f5416dc..93017f2 100644 --- a/admin_webapp/routes/ui.py +++ b/admin_webapp/routes/ui.py @@ -4,15 +4,16 @@ from datetime import timedelta, datetime from functools import wraps from flask import Blueprint, render_template, url_for, request, \ - make_response, redirect, current_app, send_file, Response + make_response, redirect, current_app, send_file, Response, session +from http import HTTPStatus as status -from arxiv import status from arxiv.base import logging -from arxiv_auth.auth.decorators import scoped +from . import admin_scoped from ..controllers import captcha_image, registration, authentication - +from admin_webapp.controllers.tapir_functions import manage_email_templates, email_template, create_email_template +from admin_webapp.controllers.endorsement import endorsement_listing_counts_only logger = logging.getLogger(__name__) blueprint = Blueprint('ui', __name__, url_prefix='') @@ -24,7 +25,10 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: if hasattr(request, 'auth') and request.auth: next_page = request.args.get('next_page', current_app.config['DEFAULT_LOGIN_REDIRECT_URL']) - return make_response(redirect(next_page, code=status.HTTP_303_SEE_OTHER)) + response = redirect(next_page, code=status.SEE_OTHER) + for key, value in request.cookies.items(): + response.set_cookie(key, value) + return response else: return func(*args, **kwargs) return wrapper @@ -106,21 +110,55 @@ def register() -> Response: """Interface for creating new accounts.""" captcha_secret = current_app.config['CAPTCHA_SECRET'] ip_address = request.remote_addr - next_page = request.args.get('next_page', url_for('account')) + next_page = request.args.get('next_page', url_for('ui.register2')) + data, code, headers = registration.register(request.method, request.form, captcha_secret, ip_address, next_page) + # flask session storage + session['email'] = data['form'].email.data + session['username'] = data['form'].username.data + session['password'] = data['form'].password.data + # Flask puts cookie-setting methods on the response, so we do that here # instead of in the controller. - if code is status.HTTP_303_SEE_OTHER: + if code is status.SEE_OTHER: response = make_response(redirect(headers['Location'], code=code)) - set_cookies(response, data) + # set_cookies(response, data) return response content = render_template("register.html", **data) response = make_response(content, code, headers) return response +@blueprint.route('/register/step2', methods=['GET', 'POST']) +@anonymous_only +def register2() -> Response: + """Interface for creating new accounts, step 2.""" + captcha_secret = current_app.config['CAPTCHA_SECRET'] + ip_address = request.remote_addr + next_page = request.args.get('next_page', url_for('account')) + + data, code, headers = registration.register2(request.method, request.form, ip_address, + next_page) + + data['email'] = session['email'] + data['username'] = session['username'] + # data['password'] = session['password'] + + # Flask puts cookie-setting methods on the response, so we do that here + # instead of in the controller. + if code is status.SEE_OTHER: + response = make_response(redirect(headers['Location'], code=code)) + set_cookies(response, data) + return response + + session.pop('test', None) + + content = render_template("register2.html", **data) + response = make_response(content, code, headers) + return response + @blueprint.route('/login', methods=['GET', 'POST']) @anonymous_only @@ -137,13 +175,14 @@ def login() -> Response: data.update({'pagetitle': 'Log in to arXiv'}) # Flask puts cookie-setting methods on the response, so we do that here # instead of in the controller. - if code is status.HTTP_303_SEE_OTHER: + if code is status.SEE_OTHER: # Set the session cookie. response = make_response(redirect(headers.get('Location'), code=code)) set_cookies(response, data) unset_submission_cookie(response) # Fix for ARXIVNG-1149. return response + logger.debug("attempted login?") # Form is invalid, or login failed. response = Response( render_template("login.html", **data), @@ -166,7 +205,7 @@ def logout() -> Response: next_page) # Flask puts cookie-setting methods on the response, so we do that here # instead of in the controller. - if code is status.HTTP_303_SEE_OTHER: + if code is status.SEE_OTHER: logger.debug('Redirecting to %s: %i', headers.get('Location'), code) response = make_response(redirect(headers.get('Location'), code=code)) set_cookies(response, data) @@ -174,7 +213,7 @@ def logout() -> Response: # Partial fix for ARXIVNG-1653, ARXIVNG-1644 unset_permanent_cookie(response) return response - return redirect(next_page, code=status.HTTP_302_FOUND) + return redirect(next_page, code=status.FOUND) @blueprint.route('/captcha', methods=['GET']) @@ -194,16 +233,51 @@ def auth_status() -> Response: @blueprint.route('/protected') -@scoped() -def an_example() -> Response: - """Example of a protected page. +@admin_scoped +def protected() -> Response: + """Example of protected landing page. - see arxiv_auth.auth.decorators in arxiv-auth for more details. + see arxiv.auth.auth.decorators in arxiv-auth for more details. """ - return make_response("This is an example of a protected page.") + counts = dict() + counts['today'] = endorsement_listing_counts_only('today') + counts['last_week'] = endorsement_listing_counts_only('last_week') + counts['open'] = endorsement_listing_counts_only('open') + return render_template('tapir-landing.html', **counts) + # return make_response("This is an example of a protected page.") @blueprint.route('/auth/v2/dev') +@admin_scoped def dev() -> Response: """Dev landing page.""" return render_template('dev.html') + +@blueprint.route('/email-template-menu', methods=['GET']) +@admin_scoped +def email_template_mgmt() -> Response: + """Email template management""" + data = manage_email_templates() + return render_template('manage_email_templates.html', **data) + +@blueprint.route('/templates/') +@admin_scoped +def template_data(template_id: int): + return render_template('email_template_display.html', **email_template(template_id)) + +@blueprint.route('templates//edit') +@admin_scoped +def template_data_edit(template_id: int): + return render_template('email_template_edit.html', **email_template(template_id)) + +@blueprint.route('/templates/create') +@admin_scoped +def create_email_template() -> Response: + return render_template('email_template_create.html') + +@blueprint.route('/templates/submit_new_template', methods=['POST']) +@admin_scoped +def flip_edit_system() -> Response: + data = create_email_template() + + return render_template() \ No newline at end of file diff --git a/admin_webapp/routes/user.py b/admin_webapp/routes/user.py index d58a48e..3ae5e8a 100644 --- a/admin_webapp/routes/user.py +++ b/admin_webapp/routes/user.py @@ -1,12 +1,133 @@ """arXiv user routes.""" -from flask import Blueprint, render_template, Response +from flask import Blueprint, render_template, Response, request, redirect +from . import admin_scoped + +from admin_webapp.controllers.users import administrator_listing, administrator_edit_sys_listing, suspect_listing, user_profile, moderator_listing, moderator_by_category_listing, flip_email_verified_flag, flip_bouncing_flag, flip_edit_users_flag, flip_edit_system_flag, non_academic_email_listing +from admin_webapp.controllers.search import general_search blueprint = Blueprint('user', __name__, url_prefix='/user') -@blueprint.route('/', methods=['GET']) -def display() -> Response: +@blueprint.route('/', methods=['GET']) +@admin_scoped +def display(user_id:int) -> Response: """Display a user.""" - return render_template('user/display.html') + if request.method == 'GET': + return render_template('user/display.html', **user_profile(user_id)) + + +# perhaps we need to scope this? +@blueprint.route('/administrators', methods=['GET']) +@admin_scoped +def administrators() -> Response: + """ + Show administrators view + """ + args = request.args + per_page = args.get('per_page', default=12, type=int) + page = args.get('page', default=1, type=int) + + data = administrator_listing(per_page, page) + data['title'] = "Administrators" + return render_template('user/list.html', **data) + +# perhaps we need to scope this? +@blueprint.route('/administrators/sys', methods=['GET']) +@admin_scoped +def administrators_sys() -> Response: + """ + Show administrators view + """ + args = request.args + per_page = args.get('per_page', default=12, type=int) + page = args.get('page', default=1, type=int) + + data = administrator_edit_sys_listing(per_page, page) + data['title'] = "Administrators" + return render_template('user/list.html', **data) + + +# perhaps we need to scope this? +@blueprint.route('/suspects', methods=['GET']) +@admin_scoped +def suspects() -> Response: + """ + Show administrators view + """ + args = request.args + per_page = args.get('per_page', default=12, type=int) + page = args.get('page', default=1, type=int) + + data = suspect_listing(per_page, page) + data['title'] = "Suspects" + return render_template('user/list.html', **data) + +@blueprint.route('/moderators', methods=['GET']) +@admin_scoped +def moderators() -> Response: + """ + Show moderators view + No pagination + """ + args = request.args + data = moderator_listing() + data['title'] = "Moderators" + return render_template('user/moderators.html', **data) + +@blueprint.route('/moderators_by_category', methods=['GET']) +@admin_scoped +def moderators_by_category() -> Response: + """ + Show moderators by category view + No pagination + """ + args = request.args + data = moderator_by_category_listing() + data['title'] = "Moderators" + return render_template('user/moderators_by_category.html', **data) + +@blueprint.route('/non_academic_emails', methods=['GET']) +@admin_scoped +def non_academic_emails() -> Response: + """ + Show users with non-academic emails + """ + args = request.args + data = non_academic_email_listing() + data['title'] = "Non-academic Emails" + return render_template('user/non_academic_emails.html', **data) + +@blueprint.route('/search', methods=['GET', 'POST']) +@admin_scoped +def search() -> Response: + args = request.args + term = args.get('search') + per_page = args.get('per_page', default=12, type=int) + page = args.get('page', default=1, type=int) + + data = general_search(term, per_page, page) + if data['count'] == 1: + return redirect('/user/' + str(data['unique_id'])) + return render_template('user/list.html', **data) + +@blueprint.route('/flip/email_verified', methods=['POST']) +@admin_scoped +def flip_email_verified() -> Response: + return flip_email_verified_flag() + +@blueprint.route('/flip/bouncing', methods=['POST']) +@admin_scoped +def flip_bouncing() -> Response: + return flip_bouncing_flag() + +@blueprint.route('flip/edit_users', methods=['POST']) +@admin_scoped +def flip_edit_users() -> Response: + return flip_edit_users_flag() + +@blueprint.route('flip/edit_system', methods=['POST']) +@admin_scoped +def flip_edit_system() -> Response: + return flip_edit_system_flag() \ No newline at end of file diff --git a/admin_webapp/static/DEMO-user-profile-style.css b/admin_webapp/static/DEMO-user-profile-style.css new file mode 100644 index 0000000..6790ce3 --- /dev/null +++ b/admin_webapp/static/DEMO-user-profile-style.css @@ -0,0 +1,460 @@ +body { + margin: 0px; + padding:0; + background: #F1E1D8; + background: #FAFAFA; + font-family: 'Open Sans', sans-serif; +} + +h1, h2, h3 { + font-family: 'Open Sans', sans-serif; + font-weight: normal; +} + +a:link, a:visited, a:active { + text-decoration: none; + } + +a:hover { + text-decoration: underline; + } +code, tt, pre { + font-family: courier; + font-size: 90%; + font-weight: bold +} +pre { + padding: 5px; + border-width: 1px; + border-style: solid; +} +.note { + font-weight: bold; + color: #b31b1b; +} +.changelog { + color: #aa9999; +} +.new { + color: #aa3333; +} + +.user-profile { + margin: 10px; +} +/*************************************** +* Define masthead styles +***************************************/ + +div#masthead { + margin: 0; + padding: 0; + width: 100%; + text-align: right; + background-color: #b31b1b; + } + +#cu-logo { + position: relative; + /* top: 0; */ + left: -20px; + top: 2px; + /* width: 300px; */ + height: 49px; +} + +#masthead h1 { + display: block; + margin: 0; + padding:0; + position: absolute; + top: 5px; + left: 15px; /* 40px with div.lines */ + font-size: 28px; + font-weight: normal; + color: white; + } + +/*************************************** +* Define menu bar styles +***************************************/ + +div#menubar { + margin: 0; + padding: 5px; + text-align: right; + background-color: #AFAFAF; + font-family: sans-serif; + font-size: 14px; + color: #fff; + } + +#menubar ul { + float: left; + margin: 0; + padding: 0; + } + +#menubar li { + display: inline; /* let's li's go next to each other */ + margin: 0; + padding: 0 10px 0 0; + } +#menubar a, a.visited{ + font-weight: bold; + color: #fff; + } + +#menubar form { + display: inline; + margin: 0; + padding: 0; + } + +#menubar input { + margin: 0; + padding: 0; + } +#menubar image{ + align="middle"; + } + +/*************************************** +* Define main body styles +***************************************/ + +div#main { + padding: 10px; + } +#main h1 { + margin: 5px 0; + font-size: 24px; +} +#main h2 { + margin: 5px 0; + padding: 5px; + font-size: 20px; + background-color: #f0eee4; +} +#main h3 { + margin: 5px 0; + font-size: 16px; + text-decoration: underline; +} + +/*************************************** +* Define left index styles +***************************************/ + +div#lindex { + position: absolute; + left: 30px; + top: 160px; + width: 380px; + } + +#lindex h2 { + background-color: #fff; + padding: 5px 0; +} + +#lindex ul{ + margin: 0; + padding: 0 25px 15px 25px; +} + + +/*************************************** +* Define right index styles +***************************************/ + +div#rindex { + position: absolute; + left: 450px; + top: 160px; + width: 380px; + } + +#rindex h2 { + background-color: #fff; + padding: 5px 0; +} + +#rindex ul{ + margin: 0; + padding: 0 25px 15px 25px; +} + +/*************************************** +* Define error box styles +***************************************/ +div#error { + margin: 5px; + padding: 10px; + width: 550px; + background-color: lightgoldenrodyellow; + border-width: 1px; + border-style: solid; +} + +/**************************************** + * Table styles + * *************************************/ + +table.tbcss +{ text-align: center; + font-family: 'Open Sans', sans-serif; + font-weight: normal; + color: #404040; +/* width: 900px;*/ + background-color: #fafafa; + border: 1px #3a4b6d solid; + border-spacing: 0px; } + +/* following style for emphasis of one cell */ +table.tbcss td.sel +{ + background-color: #FFCCCC; +} + +td.tbhead +{ border-bottom: 2px solid #3a4b6d; + border-left: 1px solid #3a4b6d; + background-color: #CCC; + text-align: left; + text-indent: 5px; + font-family: 'Open Sans', sans-serif; + font-weight: bold; + color: #404040; } + +td.tbbod,td.tbbod_odd { + border-bottom: 1px solid #3a4b6d; + border-top: 0px; + border-left: 1px solid #3a4b6d; + border-right: 0px; + margin-right: 1em; + text-align: left; + padding: 4px; + font-family: 'Open Sans', sans-serif; + font-weight: normal; + color: #404040; +} + +td.tbbod { + background-color: #FAFAFA; +} + +td.tbbod_odd { + background-color: #F2F2F2; +} + +a.linkbutton { + border: 2px solid #6e6e6e; + border-style: double; + padding: 3px; + background-color: #eee; +} + +a.sortbutton { + color: #B31B1B; + font-weight: bold; + font-size: 78%; + text-decoration: none; + vertical-align: bottom; +} + +label[for=email_mods] { + float: right; +} +input#email_mods { + float: right; +} + +.mlabel, .mglabel { + font-weight: bold; + text-align: right; + margin-right: 1em; + display: block; +} +.mglabel { + color: #909090; +} +.mglabel a:link, .mglabel a:visited, .mglabel a:active { + text-decoration: none; + color: #909090; + } +.mglabel a:hover { + text-decoration: underline; +} +.mlabel a:link, .mlabel a:visited, .mlabel a:active { + text-decoration: none; + color: #000000; + } +.mlabel a:hover { + text-decoration: underline; +} + +.mvalue, .mgvalue { + font-weight: normal; +} +.mgvalue { + color: #909090; +} + +.message { +# min-height: 20px; + font-size: 110%; + border: 2px solid #3ac90e; + padding: 3px; + background: #efefef; +} +.error { + font-size: 110%; + border: 2px solid #B31B1B; + padding: 3px; + background: #efefef; +} + +form#metaedit { + border: 1px solid #666666; + text-align: left; + font-family: Monospace, Arial, Helvetica, sans-serif; + font-size: 1em; + background-color: #CCCCCC; + color: #000000; +} + +/* otherwise the input fields have different font size */ +form#metaedit input { + font-size: 1em; +} + +table#tmetaedit th { + float: right; + font-family: 'Open Sans', sans-serif; +} + +/* Following are for admin/user/view page */ + +label { + float: left: + text-align: right; + margin-right: 0.2em; + display: block; +} +.label { + font-weight: bold; +} + +.warning { + color: red; +} + +td.editbar { background-color:rgb(128,128,255); + color: white; + font-size: 80%; + font-family: monospace; + font-weight: bold; + text-decoration: none; + } +.editbar { color: white; + font-size: 80%; + font-family: monospace; + font-weight: bold; + text-decoration: none; + } + +span.underline { + text-decoration: underline; +} + +span.boldblack { + font-weight: bold; + color: black; +} + +.proposal-wrap { + width::100%; +} +.proposal-wrap .prop-row * { + margin:0px; + padding-top: 5px; + padding-bottom: 5px; + width:19.5%; +} + +.primary span { + font-weight:bold; +} + +.prop-row span { + display:inline-block; + text-align:center; +} +.prop-row input { + display:inline-block; +} + +/* Activity log styles */ +.activity-log { + padding: 3%; +} + +.activity-log > * { + padding: 4px; +} + +.log-comment { + margin-left: 20px; +} + +/* Styles for the switch */ +.switch { + position: relative; + display: inline-block; + width: 30px; + height: 17px; +} + +/* Styles for the slider */ +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + -webkit-transition: .4s; + transition: .4s; +} + +/* Styles for the slider (before the switch is activated) */ +.slider:before { + position: absolute; + content: ""; + height: 13px; + width: 13px; + left: 2px; + bottom: 2px; + background-color: white; + -webkit-transition: .4s; + transition: .4s; +} + +/* Styles for the slider (after the switch is activated) */ +input:checked + .slider { + background-color: #2196F3; +} + +/* Styles for the slider (after the switch is activated) */ +input:checked + .slider:before { + -webkit-transform: translateX(13px); + -ms-transform: translateX(13px); + transform: translateX(13px); +} + +/* Hide the default checkbox */ +input[type=checkbox] { + display: none; +} diff --git a/admin_webapp/static/tapir-style.css b/admin_webapp/static/tapir-style.css new file mode 100644 index 0000000..e192fe7 --- /dev/null +++ b/admin_webapp/static/tapir-style.css @@ -0,0 +1,79 @@ +/* +Tapir base styles +*/ + +/* Set the background color to signature tapir color */ +body { + background-color: #FFF5E1; + } + + /* Set the font to Times New Roman for all text */ + body, p, h1, h2, h3, h4, h5, h6 { + font-family: "Times New Roman", Times, serif; + } + +.title { + text-align: center; +} + +.navbar { + font-size: 9pt; + overflow: hidden; + border-bottom: 3px solid #8080FF; +} + +/* Style for the left search bar */ +.search-bar-left { + float: left; + padding: 10px; +} + +/* Style for the center search bar */ +.search-bar-center { + float: left; + padding: 10px; +} + +/* Style for the right-justified links */ +.navbar-links { + float: right; + padding: 10px; +} + +/* Style for the links within the right-justified section */ +.navbar-links a { + margin: 0 1px; +} + +.wrapper { + display: flex; + justify-content: center; +} + +.landing-container { + display: flex; + /* flex-direction: column; */ +} + +/* landing page style */ +.list-container { + display: inline-block; + border: 1px solid #8080FF; /* Add a border for better visualization */ + padding: 10px; /* Add padding for spacing */ + margin: 3px; + flex-direction: column; + /* flex: 1; */ +} + +.list-item { + display: block; + margin-bottom: 2px; /* Add margin between items for spacing */ +} + +.indent { + padding-left: 8px; +} + +.indent-twice { + padding-left: 16px; +} \ No newline at end of file diff --git a/admin_webapp/templates/advanced_search.html b/admin_webapp/templates/advanced_search.html new file mode 100644 index 0000000..0e75990 --- /dev/null +++ b/admin_webapp/templates/advanced_search.html @@ -0,0 +1,14 @@ +{%- extends "base.html" -%} +{%- block content -%} +

Find Users

+ +
Quick Search
+
+ {% for option in fields %} + + + {%endfor%} + +
+ +{%- endblock content -%} \ No newline at end of file diff --git a/admin_webapp/templates/confirm/add-email-template-confirm.html b/admin_webapp/templates/confirm/add-email-template-confirm.html new file mode 100644 index 0000000..0a50bef --- /dev/null +++ b/admin_webapp/templates/confirm/add-email-template-confirm.html @@ -0,0 +1,9 @@ +{%- extends "base.html" -%} + +{%- block content -%} +

Confirm Add Email Template

+ +
Are you sure you want to (add an email template?)
+ + +{%- endblock content -%} \ No newline at end of file diff --git a/admin_webapp/templates/dev.html b/admin_webapp/templates/dev.html index ed52e56..a4a0359 100644 --- a/admin_webapp/templates/dev.html +++ b/admin_webapp/templates/dev.html @@ -10,9 +10,20 @@

admin webapp landing page

  • login
  • logout
  • an example protected page
  • -
  • ownership.pending
  • -
  • ownership.accepted
  • -
  • ownership.rejected
  • + + Paper Ownership Requests: + + Endorsement Requests + diff --git a/admin_webapp/templates/email_template_create.html b/admin_webapp/templates/email_template_create.html new file mode 100644 index 0000000..d1668f4 --- /dev/null +++ b/admin_webapp/templates/email_template_create.html @@ -0,0 +1,19 @@ +{%- extends "base.html" -%} +{%- block content -%} +
    Create new email template:
    +
    +
    +
    Short Name:
    + +
    Long Name:
    + +
    Data:
    + +
    + + +
    +
    +
    + +{%- endblock content -%} diff --git a/admin_webapp/templates/email_template_display.html b/admin_webapp/templates/email_template_display.html new file mode 100644 index 0000000..9b45e18 --- /dev/null +++ b/admin_webapp/templates/email_template_display.html @@ -0,0 +1,6 @@ +{%- extends "base.html" -%} +{%- block content -%} +
    + {{ template.data | safe }} +
    +{%- endblock content -%} diff --git a/admin_webapp/templates/email_template_edit.html b/admin_webapp/templates/email_template_edit.html new file mode 100644 index 0000000..6b20d76 --- /dev/null +++ b/admin_webapp/templates/email_template_edit.html @@ -0,0 +1,10 @@ +{%- extends "base.html" -%} +{%- block content -%} +

    + Edit email template +

    +
    + +
    + +{%- endblock content -%} diff --git a/admin_webapp/templates/endorsement/endorse-stage-2.html b/admin_webapp/templates/endorsement/endorse-stage-2.html new file mode 100644 index 0000000..938a3cf --- /dev/null +++ b/admin_webapp/templates/endorsement/endorse-stage-2.html @@ -0,0 +1,97 @@ +{%- extends "base.html" -%} + +{%- block content -%} +

    Do you endorse {{endorsee.nickname}}?

    + +

    The following has requested your endorsement to submit papers to the + $category $category_type ( $category_name .) Your +publication history is sufficient to endorse this user. +

    +Username: {{endorsee.nickname}}
    +Name:{{endorsee.first_name}} {{endorsee.last_name}} {{endorsee.sufix_name}}
    +E-mail address: {{ endorsee.email }}
    +Affiliation: {{ endorsee.demographics.affiliation }}
    +Country: {{ endorsee.demographics.country }}
    +TODO should be country name
    + +GOT RID OF URL: it is a security problem since it is unescaped.
    + +Registration Date: {{ endorsee.joined_date }} TODO just yyyy-mm-dd
    + +

    Who should I endorse?

    + +

    Your endorsement will enable {{ endorsee.nickname }} to upload articles to +. This is not peer-review: we don't expect you +to take more than ten minutes to make your decision. Instead, we want you +to verify that {{ endorsee.nickname }} is part of the scientific community and +will submit work that is relevant to .

    + +
      +
    • If you don't know {{ endorsee.nickname }} you must see the paper that + {{ endorsee.nickname }} intends to submit.
    • +
    • You are not being ask to confirm that the analysis or conclusions of + this paper are correct, or that the results are novel. Do not + advise {{ endorsee.nickname }} to make changes in their paper.
    • +
    • Do endorse {{ endorsee.nickname }} if their work is at all relevant + to current research in .
    • +
    • Do not endorse {{ endorsee.nickname }} if they appear ignorant of + the basic facts of the subject area, if their work is + blatantly incompatible with the basic facts of the subject + area.
    • +
    • Do not endorse {{ endorsee.nickname }} if you are not sure that you want + to do so; if {{ endorsee.nickname }} makes an inappropriate submission, your + ability to endorse other users may be suspended.
    • +
    + +

    Your choices

    + +

    You have three choices:

    +
      +
    • you can endorse {{ endorsee.nickname }},
    • +
    • you can tell us that you don't want to endorse {{ endorsee.nickname }}, or
    • +
    • do nothing.
    • +
    + +

    Do nothing if you don't know what to do. +We will not take automatic action against {{ endorsee.nickname }} if +you choose (b) +(if you choose (b) and another person endorses {{ endorsee.nickname }}, {{ endorsee.nickname }} will be +allowed to upload.) However, votes of no confidence will help us detect cases of massive +abuse (petitioners who contact a large number of people they don't know) and will call our +attention to possible problem submissions. In no case will we inform {{ endorsee.nickname }} +or other arXiv users of your choice.

    + +

    You can leave an optional comment about your endorsement (or non-endorsement) of +{{ endorsee.nickname }}. This comment will be visible to arXiv administrators but not to +{{ endorsee.nickname }} or the general public. We appreciate short comments like "She's my +student" or "He's sent me threatening messages for weeks."

    + +{% if form.errors %} + +{% endif %} + +
    + + + I {{form.choice()}} want to endorse {{endorsee.nickname}} to submit papers to + arXiv (.)TODO category +
    +
    {{form.knows_personally()}} I know {{ endorsee.nickname }} personally.
    +
    {{form.seen_paper()}} I have read the paper that {{ endorsee.nickname }} intends to submit.
    +
    (Optional) Enter any comments on why you would or would not endorse {{ endorsee.nickname }}.
    +
    {{form.comment(size=70)}}
    + +
    + +
    +  {% debug %}
    +  
    +{%- endblock content -%} \ No newline at end of file diff --git a/admin_webapp/templates/endorsement/endorse.html b/admin_webapp/templates/endorsement/endorse.html new file mode 100644 index 0000000..40df817 --- /dev/null +++ b/admin_webapp/templates/endorsement/endorse.html @@ -0,0 +1,16 @@ +{%- extends "base.html" -%} + +{%- block content -%} +

    Enter Endorsement Code

    + +
    When an arXiv user requests endorsement to submit to an archive or subject class, they receive an endorsement code. To endorse a user you must get the endorsement code for that user and enter the endorsement code into the form on this page. An endorsement code is a six character alphanumeric string (ex. H6ZX2L.) +
    + After entering the endorsement code, we will give you further instructions and ask if you wish to endorse the person who gave you the code. You'll also get the chance to make an optional comment about your endorsement or lack of endorsement -- your comment and your choice to endorse or not endorse this person will be kept private between yourself and the arXiv administration.
    + +
    + + Endorsement code: + +
    + +{%- endblock content -%} \ No newline at end of file diff --git a/admin_webapp/templates/endorsement/list.html b/admin_webapp/templates/endorsement/list.html new file mode 100644 index 0000000..6ea59b9 --- /dev/null +++ b/admin_webapp/templates/endorsement/list.html @@ -0,0 +1,39 @@ +{%- extends "base.html" -%} +{%- from "macros.html" import render_pagination with context -%} + +{%- block content -%} +

    {{title}}

    + +{%- if count > 0 -%} +
    Found {{count}} endorsements. Page {{pagination.page}} of {{pagination.pages}}.
    +{%- else -%} + +{% endif -%} + + + + + + {%for endorsement in endorsements%} + + + + + + + + + + + + + + {%endfor%} +
    IdEndorserEndorseeCategoryIssued WhenRemote HostnameValid?TypePositive?
    {{endorsement.endorsement_id}} + {{endorsement.endorser.first_name}} {{endorsement.endorser.last_name}} + {{endorsement.endorsee.first_name}} {{endorsement.endorsee.last_name}}{{endorsement}}{{endorsement.issued_when | unix_to_datetime}}{{endorsement.remote_host}}{{endorsement.flag_valid}}{{endorsement.type}}
    + +{{ render_pagination(pagination, request.endpoint) }} +{%- endblock content -%} + + \ No newline at end of file diff --git a/admin_webapp/templates/endorsement/modify.html b/admin_webapp/templates/endorsement/modify.html new file mode 100644 index 0000000..ff0b5d0 --- /dev/null +++ b/admin_webapp/templates/endorsement/modify.html @@ -0,0 +1,49 @@ +{%- extends "base.html" -%} +{% block addl_head %} + + +{% endblock addl_head %} + +{%block content%} +

    Modify lists form: WIP

    + +

    + Use this form to add email patterns to arXiv's lists of presumptively approved and blocked patterns. When entering patterns + use SQL email pattern matching syntax. If the email pattern already exists in the selected list, or is otherwise invalid, this + page will notify you. +

    + +
    +
    +
    + + + +
    + +
    + + + +
    + +
    + +
    +{%endblock content%} \ No newline at end of file diff --git a/admin_webapp/templates/endorsement/no-self-endorse.html b/admin_webapp/templates/endorsement/no-self-endorse.html new file mode 100644 index 0000000..39ee6a8 --- /dev/null +++ b/admin_webapp/templates/endorsement/no-self-endorse.html @@ -0,0 +1,10 @@ +{%- extends "base.html" -%} +{%- block content -%} +

    You can't endorse yourself!

    +

    + People cannot endorse themselves. You must find somebody else to + give you an endorsement. If you believe that you have received this + message in error or need help, please + contact help@dev.arxiv.org. +

    +{%- endblock content -%} \ No newline at end of file diff --git a/admin_webapp/templates/endorsement/request_detail.html b/admin_webapp/templates/endorsement/request_detail.html new file mode 100644 index 0000000..e713792 --- /dev/null +++ b/admin_webapp/templates/endorsement/request_detail.html @@ -0,0 +1,56 @@ +{%- extends "base.html" -%} + +{% macro yn( value ) -%} +{%- if value -%} +Y +{%- else -%} +N +{%- endif -%} +{%- endmacro %} + +{% macro flipbtn( url ) %} +
    + + +
    +{%- endmacro %} + +{%block content%} +

    Endorsement Request

    + + + +
    Category: {{endo_req.archive}}.{{endo_req.subject_class}}
    + +
    +
    + + + + + + + + + + + +
    Endorser: {% if endo_req.endorsement %} + {{endo_req.endorsement.endorser.nickname}}{% else %}(endorsement missing){% endif %}
    Valid?: {{ yn(endo_req.endorsement.flag_valid) }} {{flipbtn(url_for('endorsement.flip_valid', endorsement_req_id=endo_req.request_id))}}
    Positive?: {{ yn( endo_req.endorsement.point_value)}} {{ flipbtn(url_for('endorsement.flip_score', endorsement_req_id=endo_req.request_id))}}
    Knows Personally?: {{ yn( endo_req.audit.flag_knows_personally ) }}
    Has Seen Paper?: {{ yn(endo_req.audit.flag_seen_paper ) }}
    Type: {% if endo_req.endorsement %}{{endo_req.endorsement.type}}{% else %}(endorsement missing){% endif %}
    Points: {{endo_req.endorsement.point_value}}
    Endorsement: {% if endo_req.endorsement %}{{endo_req.endorsement.endorsement_id}}{% else %}(endorsement missing){% endif %}
    +
    + +
    + + + + + + + + +
    Endorsee: + {{endo_req.endorsee.nickname}}
    Request Id: {{endo_req.request_id}}
    Issued When: {{endo_req.issued_when}}
    Session Id: {% if endo_req.audit %}{{endo_req.audit.session_id}}{% else %}(session missing){% endif %}
    Remote Hostname:{{endo_req.audit.remote_host}}
    Remote Address: {{endo_req.audit.remote_addr}}
    Tracking Cookie: {{endo_req.audit.tracking_cookie}}
    +
    +
    Comment:{{endo_req.audit.comment}}
    +
    +{%endblock content%} \ No newline at end of file diff --git a/admin_webapp/templates/manage_email_templates.html b/admin_webapp/templates/manage_email_templates.html new file mode 100644 index 0000000..bd421c3 --- /dev/null +++ b/admin_webapp/templates/manage_email_templates.html @@ -0,0 +1,35 @@ +{%- extends "base.html" -%} +{%- block content -%} +

    Manage Email Templates

    +
    + + View + Edit + Delete +
    + + +
    + + +{%- endblock content -%} diff --git a/admin_webapp/templates/navbar.html b/admin_webapp/templates/navbar.html new file mode 100644 index 0000000..09d4375 --- /dev/null +++ b/admin_webapp/templates/navbar.html @@ -0,0 +1,20 @@ + diff --git a/admin_webapp/templates/ownership/list.html b/admin_webapp/templates/ownership/list.html index 989e5d0..5c66867 100644 --- a/admin_webapp/templates/ownership/list.html +++ b/admin_webapp/templates/ownership/list.html @@ -17,12 +17,12 @@

    {{title}}

    {%for oreq in ownership_requests%} {{oreq.request_id}} - + {{oreq.user.first_name}} {{oreq.user.last_name}} {{oreq.workflow_status}} {% if oreq.endorsement_request_id %} - {{oreq.endorsement_request_id}} + {{oreq.endorsement_request_id}} {%else%}None{%endif%} diff --git a/admin_webapp/templates/ownership/need_paper_password.html b/admin_webapp/templates/ownership/need_paper_password.html new file mode 100644 index 0000000..dbda895 --- /dev/null +++ b/admin_webapp/templates/ownership/need_paper_password.html @@ -0,0 +1,76 @@ +{%- extends "base.html" -%} +{% block content %} +

    Claim Ownership: Enter Paper ID and Password

    + + +{% if success==True %} + +{% endif %} + + +{% if error == 'already an owner' %} + +{% elif error is defined %} + +{% endif %} + + +
    + This form allows you to link your arXiv account to papers submitted + by collaborators. +
    +
    + By entering the paper ID and paper password associated with a + paper, you can become registered as the owner of a paper. + Being the owner of a paper confers a number of benefits: for + example, you can make changes to the paper, cross-list the paper and + add a journal reference. +
    +
    + Your user dashboard is where + you can view and update papers you own.
    +
    + If you register as an author of a paper, we can provide you with a + list of all the papers you have authored. Additionally, if you are + registered as the author of enough papers in a subject area, you can + endorse other people to submit papers to that subject area. +
    + +
    + +
    {{form.paperid.label}}: {{ form.paperid()}}
    +
    {{form.password.label}}: {{ form.password()}}
    +
    {{form.author.label}}: {{ form.author()}}
    +
    {{form.agree()}} + + I certify that my name is {{request.auth.user.name.forename}} + {{request.auth.user.name.surname}} (Click + here if you are not), and my email address + is {{request.auth.user.email}} + (Click here if your + email address has changed.) + +
    + + + +
    + +{% endblock content %} \ No newline at end of file diff --git a/admin_webapp/templates/paper/detail.html b/admin_webapp/templates/paper/detail.html index 7b4c53f..c52c884 100644 --- a/admin_webapp/templates/paper/detail.html +++ b/admin_webapp/templates/paper/detail.html @@ -1,4 +1,37 @@ {%- extends "base.html" -%} {%block content%} -

    Paper detail: not yet implemented

    +

    Administer paper {{document.paper_id}} [abs | PDF | edit]

    +
    +

    {{ document.title }}

    +

    {{ document.authors }}

    +

    Categories: {{ document.arXiv_document_category[0].category }}

    +
    + +
    Paper password: {{ doc_pw.password_enc }} [change]
    +
    Document ID: {{ document.document_id }}
    + +
    Paper Owners: {{ document.author }}
    +

    Submission history:

    +
    + {% for sub in sub_history %} +
    v{{ sub.version }} ⏰{{sub.created}} 👤{%if sub.submitter %} {{sub.submitter.tapir_nicknames.nickname}} {%else%} submitter ID not recorded {%endif%} ✉{{sub.submitter.email}} ({{sub.submitter.first_name}} {{sub.submitter.last_name}})
    + {%endfor%} +
    +{% if admin_log_len > 0 %} +

    Activity

    + + + + {%for log in admin_log %} + + + + + + + + {%endfor%} + +
    TimeUsernameProgram/CommandSub IDLog text
    {{log.created}}{{log.username}}{{log.program}}{{log.submission_id}}{{log.logtext | safe}}
    +{%endif%} {% endblock %} diff --git a/admin_webapp/templates/register.html b/admin_webapp/templates/register.html index c778e80..7c11fa7 100644 --- a/admin_webapp/templates/register.html +++ b/admin_webapp/templates/register.html @@ -3,7 +3,7 @@
    -

    Register for the first time

    +

    Register for the first time (Step 1 of 2)

    @@ -189,7 +189,7 @@

    Register for the first time

    - +
    diff --git a/admin_webapp/templates/register2.html b/admin_webapp/templates/register2.html new file mode 100644 index 0000000..40a449d --- /dev/null +++ b/admin_webapp/templates/register2.html @@ -0,0 +1,384 @@ +{%- extends "base/base.html" %} +{% block content %} +
    +
    + +

    Register for the first time (Step 2 of 2)

    +
    +

    Email: {{ email }}

    +

    Username: {{ username }}

    + +

    + Please supply your correct name and affiliation. +

    +

    + It is a violation of our policies to misrepresent your identity or institutional affiliation. Claimed affiliation should be current in the conventional sense: e.g., physical presence, funding, email address, mention on institutional web pages, etc. Misrepresentation of identity or affiliation, for any reason, is possible grounds for immediate and permanent suspension. +

    +
    +
    +
    + {{ form.csrf_token }} + + {% with field = form.forename %} + {{ field.label(class="label") }} +
    +
    + +
    + +
    + + {% if field.errors %} + {{ field(class="input is-warning field-body")|safe }} + {% else %} + {{ field(class="input field-body")|safe }} + {% endif %} + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + +
    + +
    +
    +
    + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} +
    +
    + {% endwith %} + + + {% with field = form.surname %} + {{ field.label(class="label") }} +
    +
    + +
    + +
    + + {% if field.errors %} + {{ field(class="input is-warning field-body")|safe }} + {% else %} + {{ field(class="input field-body")|safe }} + {% endif %} + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + +
    + +
    +
    +
    + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.suffix %} + {{ field.label(class="label") }} +
    +
    + +
    + +
    + + {% if field.errors %} + {{ field(class="input is-warning field-body")|safe }} + {% else %} + {{ field(class="input field-body")|safe }} + {% endif %} + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + +
    + +
    +
    +
    + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.affiliation %} + {{ field.label(class="label") }} +
    +
    + +
    + +
    + + {% if field.errors %} + {{ field(class="input is-warning field-body")|safe }} + {% else %} + {{ field(class="input field-body")|safe }} + {% endif %} + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + +
    + +
    +
    +
    + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.country %} + {{ field.label(class="label") }} +
    +
    + +
    + +
    + + {% if field.errors %} + {{ field(class="input is-warning field-body")|safe }} + {% else %} + {{ field(class="input field-body")|safe }} + {% endif %} + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + +
    + +
    +
    +
    + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.status %} + {{ field.label(class="label") }} +
    +
    + +
    + +
    + + {% if field.errors %} + {{ field(class="input is-warning field-body")|safe }} + {% else %} + {{ field(class="input field-body")|safe }} + {% endif %} + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + +
    + +
    +
    +
    + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.groups %} + {{ field.label(class="label") }} +
    +
    + +
    + +
    +
    + {% for value, label, checked, other in field.iter_choices() %} +
    + + +
    + {% endfor %} +
    + +
    + +
    +
    +
    + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.default_category %} + {{ field.label(class="label") }} +
    +
    + +
    + +
    + +
    + +
    +
    +
    + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.url %} + {{ field.label(class="label") }} +
    +
    + +
    + +
    + + {% if field.errors %} + {{ field(class="input is-warning field-body")|safe }} + {% else %} + {{ field(class="input field-body")|safe }} + {% endif %} + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + +
    + +
    +
    +
    + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.remember_me %} + {{ field.label(class="label") }} +
    +
    + +
    + +
    + + {% if field.errors %} + {{ field(class="checkbox is-warning field-body")|safe }} + {% else %} + {{ field(class="checkbox field-body")|safe }} + {% endif %} + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + +
    + +
    +
    +
    + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} +
    +
    + {% endwith %} + + +
    + +
    + + +
    + +
    +
    +{% endblock content %} diff --git a/admin_webapp/templates/tapir-base.html b/admin_webapp/templates/tapir-base.html new file mode 100644 index 0000000..6444311 --- /dev/null +++ b/admin_webapp/templates/tapir-base.html @@ -0,0 +1,29 @@ + + + + {% block addl_head %} + + {% endblock addl_head %} + + + {% import "base/macros.html" as macros %} + {% import "base/analytics_macros.html" as analytics_macros %} +
    + + {% block header %} + {% endblock header %} +
    + {% block navbar %} + {% include "navbar.html" %} + {% endblock %} +
    + + {% block content %} + {% endblock content %} +
    +
    + {% block footer %} + {% endblock footer %} +
    + + diff --git a/admin_webapp/templates/tapir-landing.html b/admin_webapp/templates/tapir-landing.html new file mode 100644 index 0000000..55e89b9 --- /dev/null +++ b/admin_webapp/templates/tapir-landing.html @@ -0,0 +1,50 @@ +{%- extends "tapir-base.html" -%} +{% block content %} +

    + NEW arXiv.org user administration +

    +
    +
    +
    + +
    +
    Endorsement requests:
    +
    Today: {{today}}
    +
    Last week: {{last_week}}
    +
    Open: {{open}}
    +
    +
    Endorsements:
    +
    Negative:
    +
    Today:
    +
    Last week:
    +
    +
    Ownership requests:
    +
    Pending:
    +
    Last week:
    +
    Accepted:
    +
    Rejected:
    +
    +
    +
    Functions:
    +
    + + + +
    +
    +
    Find Users:
    +
    Quick Search
    + + + +
    + + +
    + + + +
    +
    +
    +{% endblock content %} diff --git a/admin_webapp/templates/user/display.html b/admin_webapp/templates/user/display.html index ba4fa73..f72e2c5 100644 --- a/admin_webapp/templates/user/display.html +++ b/admin_webapp/templates/user/display.html @@ -1,5 +1,221 @@ -{%- extends "base.html" -%} +{% block addl_head %} + +{{ bootstrap.load_css() }} + +{% endblock addl_head %} {%block content%} -

    Single User Display: not yet implemented

    +