Add display_name field to users to specify a new name within the application, without changing the login name

This commit is contained in:
Paul Bienkowski 2022-09-13 09:45:29 +02:00
parent e8eaeab7dd
commit cd451a685d
15 changed files with 223 additions and 121 deletions

View file

@ -0,0 +1,26 @@
"""add user display_name
Revision ID: 99a3d2eb08f9
Revises: a9627f63fbed
Create Date: 2022-09-13 07:30:18.747880
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "99a3d2eb08f9"
down_revision = "a9627f63fbed"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"user", sa.Column("display_name", sa.String, nullable=True), schema="public"
)
def downgrade():
op.drop_column("user", "display_name", schema="public")

View file

@ -354,6 +354,7 @@ class User(Base):
updated_at = Column(DateTime, nullable=False, server_default=NOW, onupdate=NOW) updated_at = Column(DateTime, nullable=False, server_default=NOW, onupdate=NOW)
sub = Column(String, unique=True, nullable=False) sub = Column(String, unique=True, nullable=False)
username = Column(String, unique=True, nullable=False) username = Column(String, unique=True, nullable=False)
display_name = Column(String, nullable=True)
email = Column(String, nullable=False) email = Column(String, nullable=False)
bio = Column(TEXT) bio = Column(TEXT)
image = Column(String) image = Column(String)
@ -374,11 +375,15 @@ class User(Base):
self.api_key = secrets.token_urlsafe(24) self.api_key = secrets.token_urlsafe(24)
def to_dict(self, for_user_id=None): def to_dict(self, for_user_id=None):
return { result = {
"username": self.username, "id": self.id,
"displayName": self.display_name or self.username,
"bio": self.bio, "bio": self.bio,
"image": self.image, "image": self.image,
} }
if for_user_id == self.id:
result["username"] = self.username
return result
async def rename(self, config, new_name): async def rename(self, config, new_name):
old_name = self.username old_name = self.username

View file

@ -66,12 +66,9 @@ def get_filter_options(
* start (datetime|None) * start (datetime|None)
* end (datetime|None) * end (datetime|None)
""" """
user_id = None user_id = req.ctx.get_single_arg("user", default=None, convert=int)
username = req.ctx.get_single_arg("user", default=None) if user_id is not None and (req.ctx.user is None or req.ctx.user.id != user_id):
if username is not None: raise Forbidden()
if req.ctx.user is None or req.ctx.user.username != username:
raise Forbidden()
user_id = req.ctx.user.id
parse_date = lambda s: dateutil.parser.parse(s) parse_date = lambda s: dateutil.parser.parse(s)
start = req.ctx.get_single_arg("start", default=None, convert=parse_date) start = req.ctx.get_single_arg("start", default=None, convert=parse_date)

View file

@ -63,13 +63,13 @@ async def _return_tracks(req, extend_query, limit, offset):
async def get_tracks(req): async def get_tracks(req):
limit = req.ctx.get_single_arg("limit", default=20, convert=int) limit = req.ctx.get_single_arg("limit", default=20, convert=int)
offset = req.ctx.get_single_arg("offset", default=0, convert=int) offset = req.ctx.get_single_arg("offset", default=0, convert=int)
author = req.ctx.get_single_arg("author", default=None, convert=str) # author = req.ctx.get_single_arg("author", default=None, convert=int)
def extend_query(q): def extend_query(q):
q = q.where(Track.public) q = q.where(Track.public)
if author is not None: # if author is not None:
q = q.where(User.username == author) # q = q.where(Track.author_id == author)
return q return q

View file

@ -12,7 +12,9 @@ from obs.api import __version__ as version
def user_to_json(user): def user_to_json(user):
return { return {
"id": user.id,
"username": user.username, "username": user.username,
"displayName": user.display_name,
"email": user.email, "email": user.email,
"bio": user.bio, "bio": user.bio,
"image": user.image, "image": user.image,
@ -36,6 +38,9 @@ async def put_user(req):
if key in data and isinstance(data[key], (str, type(None))): if key in data and isinstance(data[key], (str, type(None))):
setattr(user, key, data[key]) setattr(user, key, data[key])
if "displayName" in data:
user.display_name = data["displayName"] or None
if "areTracksVisibleForAll" in data: if "areTracksVisibleForAll" in data:
user.are_tracks_visible_for_all = bool(data["areTracksVisibleForAll"]) user.are_tracks_visible_for_all = bool(data["areTracksVisibleForAll"])

View file

@ -1,39 +1,42 @@
import React from 'react' import React from "react";
import {Comment} from 'semantic-ui-react' import { Comment } from "semantic-ui-react";
import classnames from 'classnames' import classnames from "classnames";
import './styles.less' import "./styles.less";
function hashCode(s) { function hashCode(s) {
let hash = 0 let hash = 0;
for (let i = 0; i < s.length; i++) { for (let i = 0; i < s.length; i++) {
hash = (hash << 5) - hash + s.charCodeAt(i) hash = (hash << 5) - hash + s.charCodeAt(i);
hash |= 0 hash |= 0;
} }
return hash return hash;
} }
function getColor(s) { function getColor(s) {
const h = Math.floor(hashCode(s)) % 360 const h = Math.floor(hashCode(s)) % 360;
return `hsl(${h}, 50%, 50%)` return `hsl(${h}, 50%, 50%)`;
} }
export default function Avatar({user, className}) { export default function Avatar({ user, className }) {
const {image, username} = user || {} const { image, displayName } = user || {};
if (image) { if (image) {
return <Comment.Avatar src={image} className={className} /> return <Comment.Avatar src={image} className={className} />;
} }
if (!username) { if (!displayName) {
return <div className={classnames(className, 'avatar', 'empty-avatar')} /> return <div className={classnames(className, "avatar", "empty-avatar")} />;
} }
const color = getColor(username) const color = getColor(displayName);
return ( return (
<div className={classnames(className, 'avatar', 'text-avatar')} style={{background: color}}> <div
{username && <span>{username[0]}</span>} className={classnames(className, "avatar", "text-avatar")}
style={{ background: color }}
>
{displayName && <span>{displayName[0]}</span>}
</div> </div>
) );
} }

View file

@ -169,7 +169,7 @@ function MapPage({ login }) {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (login) { if (login) {
if (mapConfig.filters.currentUser) { if (mapConfig.filters.currentUser) {
query.append("user", login.username); query.append("user", login.id);
} }
if (mapConfig.filters.dateMode === "range") { if (mapConfig.filters.dateMode === "range") {

View file

@ -68,18 +68,37 @@ const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })(
<Grid.Column width={8}> <Grid.Column width={8}>
<Header as="h2">{t("SettingsPage.profile.title")}</Header> <Header as="h2">{t("SettingsPage.profile.title")}</Header>
<Message info>{t("SettingsPage.profile.publicNotice")}</Message>
<Form onSubmit={handleSubmit(onSave)} loading={loading}> <Form onSubmit={handleSubmit(onSave)} loading={loading}>
<Ref innerRef={findInput(register)}> <Form.Field error={errors?.username}>
<Form.Input <label>{t("SettingsPage.profile.username.label")}</label>
error={errors?.username} <Ref innerRef={findInput(register)}>
label={t("SettingsPage.profile.username.label")} <Input
name="username" name="username"
defaultValue={login.username} defaultValue={login.username}
disabled disabled
/> />
</Ref> </Ref>
<small>{t("SettingsPage.profile.username.hint")}</small>
</Form.Field>
<Message info visible>
{t("SettingsPage.profile.publicNotice")}
</Message>
<Form.Field error={errors?.displayName}>
<label>{t("SettingsPage.profile.displayName.label")}</label>
<Ref innerRef={findInput(register)}>
<Input
name="displayName"
defaultValue={login.displayName}
placeholder={login.username}
/>
</Ref>
<small>
{t("SettingsPage.profile.displayName.fallbackNotice")}
</small>
</Form.Field>
<Form.Field error={errors?.bio}> <Form.Field error={errors?.bio}>
<label>{t("SettingsPage.profile.bio.label")}</label> <label>{t("SettingsPage.profile.bio.label")}</label>
<Ref innerRef={register}> <Ref innerRef={register}>
@ -103,7 +122,7 @@ const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })(
<Divider /> <Divider />
<Stats user={login.username} /> <Stats user={login.id} />
</Grid.Column> </Grid.Column>
</Grid.Row> </Grid.Row>
</Grid> </Grid>
@ -140,7 +159,7 @@ function CopyInput({ value, ...props }) {
} }
position="top right" position="top right"
open={success != null} open={success != null}
content={success ? t('general.copied') : t('general.copyError')} content={success ? t("general.copied") : t("general.copyError")}
/> />
); );
} }

View file

@ -76,7 +76,7 @@ const TrackEditor = connect((state) => ({ login: state.login }))(
); );
const loading = busy || track == null; const loading = busy || track == null;
const isAuthor = login?.username === track?.author?.username; const isAuthor = login?.id === track?.author?.id;
// Navigate to track detials if we are not the author // Navigate to track detials if we are not the author
React.useEffect(() => { React.useEffect(() => {

View file

@ -60,7 +60,9 @@ export default function TrackComments({
<Comment key={comment.id}> <Comment key={comment.id}>
<Avatar user={comment.author} /> <Avatar user={comment.author} />
<Comment.Content> <Comment.Content>
<Comment.Author as="a">{comment.author.username}</Comment.Author> <Comment.Author as="a">
{comment.author.displayName}
</Comment.Author>
<Comment.Metadata> <Comment.Metadata>
<div> <div>
<FormattedDate date={comment.createdAt} relative /> <FormattedDate date={comment.createdAt} relative />
@ -69,7 +71,7 @@ export default function TrackComments({
<Comment.Text> <Comment.Text>
<Markdown>{comment.body}</Markdown> <Markdown>{comment.body}</Markdown>
</Comment.Text> </Comment.Text>
{login?.username === comment.author.username && ( {login?.id === comment.author.id && (
<Comment.Actions> <Comment.Actions>
<Comment.Action <Comment.Action
onClick={(e) => { onClick={(e) => {
@ -77,7 +79,7 @@ export default function TrackComments({
e.preventDefault(); e.preventDefault();
}} }}
> >
{t('general.delete')} {t("general.delete")}
</Comment.Action> </Comment.Action>
</Comment.Actions> </Comment.Actions>
)} )}

View file

@ -246,7 +246,7 @@ const TrackPage = connect((state) => ({ login: state.login }))(
[slug] [slug]
); );
const isAuthor = login?.username === data?.track?.author?.username; const isAuthor = login?.id === data?.track?.author?.id;
const { track, trackData, comments } = data || {}; const { track, trackData, comments } = data || {};
@ -355,7 +355,7 @@ const TrackPage = connect((state) => ({ login: state.login }))(
onConfirm={hideDownloadError} onConfirm={hideDownloadError}
header={t("TrackPage.downloadFailed")} header={t("TrackPage.downloadFailed")}
content={String(downloadError)} content={String(downloadError)}
confirmButton={t('general.ok')} confirmButton={t("general.ok")}
/> />
</Page> </Page>
); );

View file

@ -1,49 +1,65 @@
import React, {useCallback} from 'react' import React, { useCallback } from "react";
import {connect} from 'react-redux' import { connect } from "react-redux";
import {Button, Message, Item, Header, Loader, Pagination, Icon} from 'semantic-ui-react' import {
import {useObservable} from 'rxjs-hooks' Button,
import {Link} from 'react-router-dom' Message,
import {of, from, concat} from 'rxjs' Item,
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators' Header,
import _ from 'lodash' Loader,
import {useTranslation, Trans as Translate} from 'react-i18next' Pagination,
Icon,
} from "semantic-ui-react";
import { useObservable } from "rxjs-hooks";
import { Link } from "react-router-dom";
import { of, from, concat } from "rxjs";
import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
import _ from "lodash";
import { useTranslation, Trans as Translate } from "react-i18next";
import type {Track} from 'types' import type { Track } from "types";
import {Avatar, Page, StripMarkdown, FormattedDate, Visibility} from 'components' import {
import api from 'api' Avatar,
import {useQueryParam} from 'query' Page,
StripMarkdown,
FormattedDate,
Visibility,
} from "components";
import api from "api";
import { useQueryParam } from "query";
function TrackList({privateTracks}: {privateTracks: boolean}) { function TrackList({ privateTracks }: { privateTracks: boolean }) {
const [page, setPage] = useQueryParam<number>('page', 1, Number) const [page, setPage] = useQueryParam<number>("page", 1, Number);
const pageSize = 10 const pageSize = 10;
const data: { const data: {
tracks: Track[] tracks: Track[];
trackCount: number trackCount: number;
} | null = useObservable( } | null = useObservable(
(_$, inputs$) => (_$, inputs$) =>
inputs$.pipe( inputs$.pipe(
map(([page, privateTracks]) => { map(([page, privateTracks]) => {
const url = '/tracks' + (privateTracks ? '/feed' : '') const url = "/tracks" + (privateTracks ? "/feed" : "");
const query = {limit: pageSize, offset: pageSize * (page - 1)} const query = { limit: pageSize, offset: pageSize * (page - 1) };
return {url, query} return { url, query };
}), }),
distinctUntilChanged(_.isEqual), distinctUntilChanged(_.isEqual),
switchMap((request) => concat(of(null), from(api.get(request.url, {query: request.query})))) switchMap((request) =>
concat(of(null), from(api.get(request.url, { query: request.query })))
)
), ),
null, null,
[page, privateTracks] [page, privateTracks]
) );
const {tracks, trackCount} = data || {tracks: [], trackCount: 0} const { tracks, trackCount } = data || { tracks: [], trackCount: 0 };
const loading = !data const loading = !data;
const totalPages = Math.ceil(trackCount / pageSize) const totalPages = Math.ceil(trackCount / pageSize);
const {t} = useTranslation() const { t } = useTranslation();
return ( return (
<div> <div>
<Loader content={t('general.loading')} active={loading} /> <Loader content={t("general.loading")} active={loading} />
{!loading && totalPages > 1 && ( {!loading && totalPages > 1 && (
<Pagination <Pagination
activePage={page} activePage={page}
@ -55,14 +71,14 @@ function TrackList({privateTracks}: {privateTracks: boolean}) {
{tracks && tracks.length ? ( {tracks && tracks.length ? (
<Item.Group divided> <Item.Group divided>
{tracks.map((track: Track) => ( {tracks.map((track: Track) => (
<TrackListItem key={track.slug} {...{track, privateTracks}} /> <TrackListItem key={track.slug} {...{ track, privateTracks }} />
))} ))}
</Item.Group> </Item.Group>
) : ( ) : (
<NoPublicTracksMessage /> <NoPublicTracksMessage />
)} )}
</div> </div>
) );
} }
export function NoPublicTracksMessage() { export function NoPublicTracksMessage() {
@ -72,27 +88,27 @@ export function NoPublicTracksMessage() {
No public tracks yet. <Link to="/upload">Upload the first!</Link> No public tracks yet. <Link to="/upload">Upload the first!</Link>
</Translate> </Translate>
</Message> </Message>
) );
} }
function maxLength(t: string|null, max: number): string|null { function maxLength(t: string | null, max: number): string | null {
if (t && t.length > max) { if (t && t.length > max) {
return t.substring(0, max) + ' ...' return t.substring(0, max) + " ...";
} else { } else {
return t return t;
} }
} }
const COLOR_BY_STATUS = { const COLOR_BY_STATUS = {
error: 'red', error: "red",
complete: 'green', complete: "green",
created: 'gray', created: "gray",
queued: 'orange', queued: "orange",
processing: 'orange', processing: "orange",
} };
export function TrackListItem({track, privateTracks = false}) { export function TrackListItem({ track, privateTracks = false }) {
const {t} = useTranslation() const { t } = useTranslation();
return ( return (
<Item key={track.slug}> <Item key={track.slug}>
@ -101,10 +117,14 @@ export function TrackListItem({track, privateTracks = false}) {
</Item.Image> </Item.Image>
<Item.Content> <Item.Content>
<Item.Header as={Link} to={`/tracks/${track.slug}`}> <Item.Header as={Link} to={`/tracks/${track.slug}`}>
{track.title || t('general.unnamedTrack')} {track.title || t("general.unnamedTrack")}
</Item.Header> </Item.Header>
<Item.Meta> <Item.Meta>
{privateTracks ? null : <span>{t('TracksPage.createdBy', {author: track.author.username})}</span>} {privateTracks ? null : (
<span>
{t("TracksPage.createdBy", { author: track.author.displayName })}
</span>
)}
<span> <span>
<FormattedDate date={track.createdAt} /> <FormattedDate date={track.createdAt} />
</span> </span>
@ -116,45 +136,57 @@ export function TrackListItem({track, privateTracks = false}) {
<Item.Extra> <Item.Extra>
<Visibility public={track.public} /> <Visibility public={track.public} />
<span style={{marginLeft: '1em'}}> <span style={{ marginLeft: "1em" }}>
<Icon color={COLOR_BY_STATUS[track.processingStatus]} name="bolt" fitted /> <Icon
{' '} color={COLOR_BY_STATUS[track.processingStatus]}
name="bolt"
fitted
/>{" "}
{t(`TracksPage.processing.${track.processingStatus}`)} {t(`TracksPage.processing.${track.processingStatus}`)}
</span> </span>
</Item.Extra> </Item.Extra>
)} )}
</Item.Content> </Item.Content>
</Item> </Item>
) );
} }
function UploadButton({navigate, ...props}) { function UploadButton({ navigate, ...props }) {
const {t} = useTranslation() const { t } = useTranslation();
const onClick = useCallback( const onClick = useCallback(
(e) => { (e) => {
e.preventDefault() e.preventDefault();
navigate() navigate();
}, },
[navigate] [navigate]
) );
return ( return (
<Button onClick={onClick} {...props} color="green" style={{float: 'right'}}> <Button
{t('TracksPage.upload')} onClick={onClick}
{...props}
color="green"
style={{ float: "right" }}
>
{t("TracksPage.upload")}
</Button> </Button>
) );
} }
const TracksPage = connect((state) => ({login: (state as any).login}))(function TracksPage({login, privateTracks}) { const TracksPage = connect((state) => ({ login: (state as any).login }))(
const {t} = useTranslation() function TracksPage({ login, privateTracks }) {
const title = privateTracks ? t('TracksPage.titleUser') : t('TracksPage.titlePublic') const { t } = useTranslation();
const title = privateTracks
? t("TracksPage.titleUser")
: t("TracksPage.titlePublic");
return ( return (
<Page title={title}> <Page title={title}>
<Header as='h2'>{title}</Header> <Header as="h2">{title}</Header>
{privateTracks && <Link component={UploadButton} to="/upload" />} {privateTracks && <Link component={UploadButton} to="/upload" />}
<TrackList {...{privateTracks}} /> <TrackList {...{ privateTracks }} />
</Page> </Page>
) );
}) }
);
export default TracksPage export default TracksPage;

View file

@ -206,9 +206,15 @@ SettingsPage:
profile: profile:
title: Mein Profil title: Mein Profil
publicNotice: All diese Informationen sind öffentlich. publicNotice: Alle Informationen ab hier sind öffentlich.
username: username:
label: Kontoname label: Kontoname
hint: >
Der Kontoname kann beim Login-Dienst geändert werden, und wird beim
nächsten Login übernommen.
displayName:
label: Anzeigename
fallbackNotice: Sofern kein Anzeigename gewählt ist wird der Kontoname öffentlich angezeigt.
bio: bio:
label: Bio label: Bio
avatarUrl: avatarUrl:

View file

@ -210,9 +210,15 @@ SettingsPage:
profile: profile:
title: My profile title: My profile
publicNotice: All of this information is public. publicNotice: All of the information below is public.
username: username:
label: Username label: Username
hint: >
The username can be changed at the login provider and will be applied
when logging in the next time.
displayName:
label: Display name
fallbackNotice: As long as no display name is chosen, the username is shown publicly.
bio: bio:
label: Bio label: Bio
avatarUrl: avatarUrl:

View file

@ -1,7 +1,8 @@
import type {FeatureCollection, Feature, LineString, Point} from 'geojson' import type {FeatureCollection, Feature, LineString, Point} from 'geojson'
export type UserProfile = { export type UserProfile = {
username: string id: number | string
displayName: string
image?: string | null image?: string | null
bio?: string | null bio?: string | null
} }