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

View file

@ -66,12 +66,9 @@ def get_filter_options(
* start (datetime|None)
* end (datetime|None)
"""
user_id = None
username = req.ctx.get_single_arg("user", default=None)
if username is not None:
if req.ctx.user is None or req.ctx.user.username != username:
user_id = req.ctx.get_single_arg("user", default=None, convert=int)
if user_id is not None and (req.ctx.user is None or req.ctx.user.id != user_id):
raise Forbidden()
user_id = req.ctx.user.id
parse_date = lambda s: dateutil.parser.parse(s)
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):
limit = req.ctx.get_single_arg("limit", default=20, 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):
q = q.where(Track.public)
if author is not None:
q = q.where(User.username == author)
# if author is not None:
# q = q.where(Track.author_id == author)
return q

View file

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

View file

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

View file

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

View file

@ -68,18 +68,37 @@ const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })(
<Grid.Column width={8}>
<Header as="h2">{t("SettingsPage.profile.title")}</Header>
<Message info>{t("SettingsPage.profile.publicNotice")}</Message>
<Form onSubmit={handleSubmit(onSave)} loading={loading}>
<Form.Field error={errors?.username}>
<label>{t("SettingsPage.profile.username.label")}</label>
<Ref innerRef={findInput(register)}>
<Form.Input
error={errors?.username}
label={t("SettingsPage.profile.username.label")}
<Input
name="username"
defaultValue={login.username}
disabled
/>
</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}>
<label>{t("SettingsPage.profile.bio.label")}</label>
<Ref innerRef={register}>
@ -103,7 +122,7 @@ const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })(
<Divider />
<Stats user={login.username} />
<Stats user={login.id} />
</Grid.Column>
</Grid.Row>
</Grid>
@ -140,7 +159,7 @@ function CopyInput({ value, ...props }) {
}
position="top right"
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 isAuthor = login?.username === track?.author?.username;
const isAuthor = login?.id === track?.author?.id;
// Navigate to track detials if we are not the author
React.useEffect(() => {

View file

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

View file

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

View file

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

View file

@ -206,9 +206,15 @@ SettingsPage:
profile:
title: Mein Profil
publicNotice: All diese Informationen sind öffentlich.
publicNotice: Alle Informationen ab hier sind öffentlich.
username:
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:
label: Bio
avatarUrl:

View file

@ -210,9 +210,15 @@ SettingsPage:
profile:
title: My profile
publicNotice: All of this information is public.
publicNotice: All of the information below is public.
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:
label: Bio
avatarUrl:

View file

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