Merge branch 'allow-to-remove-pictures' into 'master'

Allow to remove pictures and show user media size usage

Closes #281

See merge request framasoft/mobilizon!721
This commit is contained in:
Thomas Citharel 2020-11-23 17:19:22 +01:00
commit a368c9542b
33 changed files with 786 additions and 137 deletions

View File

@ -19,7 +19,6 @@ config :mobilizon, Mobilizon.Web.Endpoint,
code_reloader: true, code_reloader: true,
check_origin: false, check_origin: false,
watchers: [ watchers: [
# yarn: ["run", "dev", cd: Path.expand("../js", __DIR__)]
node: [ node: [
"node_modules/webpack/bin/webpack.js", "node_modules/webpack/bin/webpack.js",
"--mode", "--mode",
@ -53,8 +52,8 @@ config :mobilizon, Mobilizon.Web.Endpoint,
patterns: [ patterns: [
~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
~r{priv/gettext/.*(po)$}, ~r{priv/gettext/.*(po)$},
~r{lib/mobilizon_web/views/.*(ex)$}, ~r{lib/web/(live|views)/.*(ex)$},
~r{lib/mobilizon_web/templates/.*(eex)$} ~r{lib/web/templates/.*(eex)$}
] ]
] ]

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="root"> <div class="root">
<figure class="image" v-if="actualImageSrc"> <figure class="image" v-if="imageSrc">
<img :src="actualImageSrc" /> <img :src="imageSrc" />
</figure> </figure>
<figure class="image is-128x128" v-else> <figure class="image is-128x128" v-else>
<div class="image-placeholder"> <div class="image-placeholder">
@ -9,12 +9,19 @@
</div> </div>
</figure> </figure>
<b-upload @input="onFileChanged" :accept="accept"> <div class="action-buttons">
<a class="button is-primary"> <b-field class="file is-primary">
<b-icon icon="upload"></b-icon> <b-upload @input="onFileChanged" :accept="accept" class="file-label">
<span>{{ $t("Click to upload") }}</span> <span class="file-cta">
</a> <b-icon class="file-icon" icon="upload" />
</b-upload> <span>{{ $t("Click to upload") }}</span>
</span>
</b-upload>
</b-field>
<b-button type="is-text" v-if="imageSrc" @click="removeOrClearPicture">
{{ $t("Clear") }}
</b-button>
</div>
</div> </div>
</template> </template>
@ -45,16 +52,22 @@ figure.image {
color: #eee; color: #eee;
} }
} }
.action-buttons {
display: flex;
flex-direction: column;
}
</style> </style>
<script lang="ts"> <script lang="ts">
import { IPicture } from "@/types/picture.model";
import { Component, Model, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Model, Prop, Vue, Watch } from "vue-property-decorator";
@Component @Component
export default class PictureUpload extends Vue { export default class PictureUpload extends Vue {
@Model("change", { type: File }) readonly pictureFile!: File; @Model("change", { type: File }) readonly pictureFile!: File;
@Prop({ type: String, required: false }) defaultImageSrc!: string; @Prop({ type: Object, required: false }) defaultImage!: IPicture;
@Prop({ type: String, required: false, default: "image/gif,image/png,image/jpeg,image/webp" }) @Prop({ type: String, required: false, default: "image/gif,image/png,image/jpeg,image/webp" })
accept!: string; accept!: string;
@ -70,24 +83,40 @@ export default class PictureUpload extends Vue {
}) })
textFallback!: string; textFallback!: string;
imageSrc: string | null = null; imageSrc: string | null = this.defaultImage ? this.defaultImage.url : null;
file!: File | null;
mounted(): void { mounted(): void {
this.updatePreview(this.pictureFile); if (this.pictureFile) {
this.updatePreview(this.pictureFile);
}
} }
@Watch("pictureFile") @Watch("pictureFile")
onPictureFileChanged(val: File): void { onPictureFileChanged(val: File): void {
console.log("onPictureFileChanged", val);
this.updatePreview(val); this.updatePreview(val);
} }
onFileChanged(file: File): void { @Watch("defaultImage")
onDefaultImageChange(defaultImage: IPicture): void {
console.log("onDefaultImageChange", defaultImage);
this.imageSrc = defaultImage ? defaultImage.url : null;
}
onFileChanged(file: File | null): void {
this.$emit("change", file); this.$emit("change", file);
this.updatePreview(file); this.updatePreview(file);
this.file = file;
} }
private updatePreview(file?: File) { async removeOrClearPicture(): Promise<void> {
this.onFileChanged(null);
}
private updatePreview(file?: File | null) {
if (file) { if (file) {
this.imageSrc = URL.createObjectURL(file); this.imageSrc = URL.createObjectURL(file);
return; return;
@ -95,9 +124,5 @@ export default class PictureUpload extends Vue {
this.imageSrc = null; this.imageSrc = null;
} }
get actualImageSrc(): string | null {
return this.imageSrc || this.defaultImageSrc;
}
} }
</script> </script>

View File

@ -10,6 +10,7 @@ export const FETCH_PERSON = gql`
summary summary
preferredUsername preferredUsername
suspended suspended
mediaSize
avatar { avatar {
id id
name name
@ -51,6 +52,7 @@ export const GET_PERSON = gql`
summary summary
preferredUsername preferredUsername
suspended suspended
mediaSize
avatar { avatar {
id id
name name

View File

@ -84,6 +84,7 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
id id
url url
} }
mediaSize
organizedEvents( organizedEvents(
afterDatetime: $afterDateTime afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime beforeDatetime: $beforeDateTime

View File

@ -1,6 +1,5 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
/* eslint-disable import/prefer-default-export */
export const UPLOAD_PICTURE = gql` export const UPLOAD_PICTURE = gql`
mutation UploadPicture($file: Upload!, $alt: String, $name: String!) { mutation UploadPicture($file: Upload!, $alt: String, $name: String!) {
uploadPicture(file: $file, alt: $alt, name: $name) { uploadPicture(file: $file, alt: $alt, name: $name) {
@ -9,3 +8,11 @@ export const UPLOAD_PICTURE = gql`
} }
} }
`; `;
export const REMOVE_PICTURE = gql`
mutation RemovePicture($id: ID!) {
removePicture(id: $id) {
id
}
}
`;

View File

@ -200,6 +200,7 @@ export const GET_USER = gql`
currentSignInAt currentSignInAt
locale locale
disabled disabled
mediaSize
defaultActor { defaultActor {
id id
} }

View File

@ -799,5 +799,6 @@
"Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want.": "Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want.", "Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want.": "Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want.",
"Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere.": "Mobilizon is a federated software, meaning you can interact - depending on your admin federation settings - with content from other instances, such as joining groups or events that were created elsewhere.", "Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere.": "Mobilizon is a federated software, meaning you can interact - depending on your admin federation settings - with content from other instances, such as joining groups or events that were created elsewhere.",
"This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.": "This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.", "This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.": "This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.",
"If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:": "If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:" "If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:": "If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:",
"Uploaded media size": "Uploaded media size"
} }

View File

@ -887,5 +887,6 @@
"Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want.": "Mobilizon utilise un système de profils pour compartimenter vos activités. Vous pourrez créer autant de profils que vous voulez.", "Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want.": "Mobilizon utilise un système de profils pour compartimenter vos activités. Vous pourrez créer autant de profils que vous voulez.",
"Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere.": "Mobilizon est un logiciel fédéré, ce qui signifie que vous pouvez interagir - en fonction des paramètres de fédération de votre administrateur·ice - avec du contenu d'autres instances, comme par exemple rejoindre des groupes ou des événements ayant été créés ailleurs.", "Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere.": "Mobilizon est un logiciel fédéré, ce qui signifie que vous pouvez interagir - en fonction des paramètres de fédération de votre administrateur·ice - avec du contenu d'autres instances, comme par exemple rejoindre des groupes ou des événements ayant été créés ailleurs.",
"This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.": "Cette instance, <b>{instanceName} ({domain})</b>, héberge votre profil, donc notez bien son nom.", "This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.": "Cette instance, <b>{instanceName} ({domain})</b>, héberge votre profil, donc notez bien son nom.",
"If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:": "Si l'on vous demande votre identité fédérée, elle est composée de votre nom d'utilisateur·ice et de votre instance. Par exemple, l'identité fédérée de votre premier profil est :" "If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:": "Si l'on vous demande votre identité fédérée, elle est composée de votre nom d'utilisateur·ice et de votre instance. Par exemple, l'identité fédérée de votre premier profil est :",
"Uploaded media size": "Taille des médias téléversés"
} }

View File

@ -13,6 +13,7 @@ export interface IActor {
url: string; url: string;
name: string; name: string;
domain: string | null; domain: string | null;
mediaSize: number;
summary: string; summary: string;
preferredUsername: string; preferredUsername: string;
suspended: boolean; suspended: boolean;
@ -30,6 +31,8 @@ export class Actor implements IActor {
domain: string | null = null; domain: string | null = null;
mediaSize = 0;
name = ""; name = "";
preferredUsername = ""; preferredUsername = "";

View File

@ -39,6 +39,7 @@ export interface IUser extends ICurrentUser {
actors: IPerson[]; actors: IPerson[];
disabled: boolean; disabled: boolean;
participations: Paginate<IParticipant>; participations: Paginate<IParticipant>;
mediaSize: number;
drafts: IEvent[]; drafts: IEvent[];
settings: IUserSettings; settings: IUserSettings;
locale: string; locale: string;

View File

@ -69,7 +69,7 @@ interface IEventEditJSON {
visibility: EventVisibility; visibility: EventVisibility;
joinOptions: EventJoinOptions; joinOptions: EventJoinOptions;
draft: boolean; draft: boolean;
picture: IPicture | { pictureId: string } | null; picture?: IPicture | { pictureId: string } | null;
attributedToId: string | null; attributedToId: string | null;
onlineAddress?: string; onlineAddress?: string;
phoneAddress?: string; phoneAddress?: string;
@ -234,7 +234,6 @@ export class EventModel implements IEvent {
joinOptions: this.joinOptions, joinOptions: this.joinOptions,
draft: this.draft, draft: this.draft,
tags: this.tags.map((t) => t.title), tags: this.tags.map((t) => t.title),
picture: this.picture,
onlineAddress: this.onlineAddress, onlineAddress: this.onlineAddress,
phoneAddress: this.phoneAddress, phoneAddress: this.phoneAddress,
physicalAddress: this.physicalAddress, physicalAddress: this.physicalAddress,

View File

@ -18,4 +18,17 @@ function localeShortWeekDayNames(): string[] {
return weekDayNames; return weekDayNames;
} }
export { localeMonthNames, localeShortWeekDayNames }; // https://stackoverflow.com/a/18650828/10204399
function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
}
export { localeMonthNames, localeShortWeekDayNames, formatBytes };

View File

@ -9,7 +9,7 @@ export async function buildFileFromIPicture(obj: IPicture | null | undefined): P
return new File([blob], obj.name); return new File([blob], obj.name);
} }
export function buildFileVariable<T>(file: File | null, name: string, alt?: string): Record<string, unknown> { export function buildFileVariable(file: File | null, name: string, alt?: string): Record<string, unknown> {
if (!file) return {}; if (!file) return {};
return { return {

View File

@ -27,7 +27,7 @@
<span v-else>{{ $t("I create an identity") }}</span> <span v-else>{{ $t("I create an identity") }}</span>
</h1> </h1>
<picture-upload v-model="avatarFile" :defaultImageSrc="avatarUrl" class="picture-upload" /> <picture-upload v-model="avatarFile" :defaultImage="identity.avatar" class="picture-upload" />
<b-field horizontal :label="$t('Display name')"> <b-field horizontal :label="$t('Display name')">
<b-input <b-input
@ -124,6 +124,7 @@ h1 {
<script lang="ts"> <script lang="ts">
import { Component, Prop, Watch } from "vue-property-decorator"; import { Component, Prop, Watch } from "vue-property-decorator";
import { mixins } from "vue-class-component"; import { mixins } from "vue-class-component";
import { IPicture } from "@/types/picture.model";
import { import {
CREATE_PERSON, CREATE_PERSON,
CURRENT_ACTOR_CLIENT, CURRENT_ACTOR_CLIENT,
@ -136,7 +137,7 @@ import { IPerson, Person } from "../../../types/actor";
import PictureUpload from "../../../components/PictureUpload.vue"; import PictureUpload from "../../../components/PictureUpload.vue";
import { MOBILIZON_INSTANCE_HOST } from "../../../api/_entrypoint"; import { MOBILIZON_INSTANCE_HOST } from "../../../api/_entrypoint";
import RouteName from "../../../router/name"; import RouteName from "../../../router/name";
import { buildFileVariable } from "../../../utils/image"; import { buildFileFromIPicture, buildFileVariable } from "../../../utils/image";
import { changeIdentity } from "../../../utils/auth"; import { changeIdentity } from "../../../utils/auth";
import identityEditionMixin from "../../../mixins/identityEdition"; import identityEditionMixin from "../../../mixins/identityEdition";
@ -186,13 +187,6 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
) as string; ) as string;
} }
get avatarUrl(): string | null {
if (this.identity && this.identity.avatar && this.identity.avatar.url) {
return this.identity.avatar.url;
}
return null;
}
@Watch("isUpdate") @Watch("isUpdate")
async isUpdateChanged(): Promise<void> { async isUpdateChanged(): Promise<void> {
this.resetFields(); this.resetFields();
@ -286,7 +280,6 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
} }
}, },
}); });
this.avatarFile = null;
this.$notifier.success( this.$notifier.success(
this.$t("Identity {displayName} updated", { this.$t("Identity {displayName} updated", {

View File

@ -198,6 +198,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator"; import { Component, Vue, Prop } from "vue-property-decorator";
import { GET_GROUP, REFRESH_PROFILE } from "@/graphql/group"; import { GET_GROUP, REFRESH_PROFILE } from "@/graphql/group";
import { formatBytes } from "@/utils/datetime";
import { SUSPEND_PROFILE, UNSUSPEND_PROFILE } from "../../graphql/actor"; import { SUSPEND_PROFILE, UNSUSPEND_PROFILE } from "../../graphql/actor";
import { IGroup, MemberRole } from "../../types/actor"; import { IGroup, MemberRole } from "../../types/actor";
import { usernameWithDomain, IActor } from "../../types/actor/actor.model"; import { usernameWithDomain, IActor } from "../../types/actor/actor.model";
@ -258,6 +259,10 @@ export default class AdminGroupProfile extends Vue {
key: this.$t("Domain") as string, key: this.$t("Domain") as string,
value: (this.group.domain ? this.group.domain : this.$t("Local")) as string, value: (this.group.domain ? this.group.domain : this.$t("Local")) as string,
}, },
{
key: this.$i18n.t("Uploaded media size") as string,
value: formatBytes(this.group.mediaSize),
},
]; ];
return res; return res;
} }

View File

@ -126,11 +126,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator"; import { Component, Vue, Prop } from "vue-property-decorator";
import { formatBytes } from "@/utils/datetime";
import { GET_PERSON, SUSPEND_PROFILE, UNSUSPEND_PROFILE } from "../../graphql/actor"; import { GET_PERSON, SUSPEND_PROFILE, UNSUSPEND_PROFILE } from "../../graphql/actor";
import { IPerson } from "../../types/actor"; import { IPerson } from "../../types/actor";
import { usernameWithDomain } from "../../types/actor/actor.model"; import { usernameWithDomain } from "../../types/actor/actor.model";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { IEvent } from "../../types/event.model";
import ActorCard from "../../components/Account/ActorCard.vue"; import ActorCard from "../../components/Account/ActorCard.vue";
const EVENTS_PER_PAGE = 10; const EVENTS_PER_PAGE = 10;
@ -171,9 +171,9 @@ export default class AdminProfile extends Vue {
participationsPage = 1; participationsPage = 1;
get metadata(): Array<object> { get metadata(): Array<Record<string, unknown>> {
if (!this.person) return []; if (!this.person) return [];
const res: object[] = [ const res: Record<string, unknown>[] = [
{ {
key: this.$t("Status") as string, key: this.$t("Status") as string,
value: this.person.suspended ? this.$t("Suspended") : this.$t("Active"), value: this.person.suspended ? this.$t("Suspended") : this.$t("Active"),
@ -182,6 +182,10 @@ export default class AdminProfile extends Vue {
key: this.$t("Domain") as string, key: this.$t("Domain") as string,
value: this.person.domain ? this.person.domain : this.$t("Local"), value: this.person.domain ? this.person.domain : this.$t("Local"),
}, },
{
key: this.$i18n.t("Uploaded media size"),
value: formatBytes(this.person.mediaSize),
},
]; ];
if (!this.person.domain && this.person.user) { if (!this.person.domain && this.person.user) {
res.push({ res.push({
@ -193,7 +197,7 @@ export default class AdminProfile extends Vue {
return res; return res;
} }
async suspendProfile() { async suspendProfile(): Promise<void> {
this.$apollo.mutate<{ suspendProfile: { id: string } }>({ this.$apollo.mutate<{ suspendProfile: { id: string } }>({
mutation: SUSPEND_PROFILE, mutation: SUSPEND_PROFILE,
variables: { variables: {
@ -229,7 +233,7 @@ export default class AdminProfile extends Vue {
}); });
} }
async unsuspendProfile() { async unsuspendProfile(): Promise<void> {
const profileID = this.id; const profileID = this.id;
this.$apollo.mutate<{ unsuspendProfile: { id: string } }>({ this.$apollo.mutate<{ unsuspendProfile: { id: string } }>({
mutation: UNSUSPEND_PROFILE, mutation: UNSUSPEND_PROFILE,
@ -249,7 +253,7 @@ export default class AdminProfile extends Vue {
}); });
} }
async onOrganizedEventsPageChange(page: number) { async onOrganizedEventsPageChange(page: number): Promise<void> {
this.organizedEventsPage = page; this.organizedEventsPage = page;
await this.$apollo.queries.person.fetchMore({ await this.$apollo.queries.person.fetchMore({
variables: { variables: {
@ -274,7 +278,7 @@ export default class AdminProfile extends Vue {
}); });
} }
async onParticipationsPageChange(page: number) { async onParticipationsPageChange(page: number): Promise<void> {
this.participationsPage = page; this.participationsPage = page;
await this.$apollo.queries.person.fetchMore({ await this.$apollo.queries.person.fetchMore({
variables: { variables: {

View File

@ -26,7 +26,7 @@
</nav> </nav>
<table v-if="metadata.length > 0" class="table is-fullwidth"> <table v-if="metadata.length > 0" class="table is-fullwidth">
<tbody> <tbody>
<tr v-for="{ key, value, link, elements } in metadata" :key="key"> <tr v-for="{ key, value, link, elements, type } in metadata" :key="key">
<td>{{ key }}</td> <td>{{ key }}</td>
<td v-if="elements && elements.length > 0"> <td v-if="elements && elements.length > 0">
<ul v-for="{ value, link: elementLink, active } in elements" :key="value"> <ul v-for="{ value, link: elementLink, active } in elements" :key="value">
@ -46,6 +46,9 @@
{{ value }} {{ value }}
</router-link> </router-link>
</td> </td>
<td v-else-if="type == 'code'">
<code>{{ value }}</code>
</td>
<td v-else>{{ value }}</td> <td v-else>{{ value }}</td>
</tr> </tr>
</tbody> </tbody>
@ -60,6 +63,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator"; import { Component, Vue, Prop } from "vue-property-decorator";
import { Route } from "vue-router"; import { Route } from "vue-router";
import { formatBytes } from "@/utils/datetime";
import { GET_USER, SUSPEND_USER } from "../../graphql/user"; import { GET_USER, SUSPEND_USER } from "../../graphql/user";
import { usernameWithDomain } from "../../types/actor/actor.model"; import { usernameWithDomain } from "../../types/actor/actor.model";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@ -139,11 +143,16 @@ export default class AdminUserProfile extends Vue {
{ {
key: this.$i18n.t("Last IP adress"), key: this.$i18n.t("Last IP adress"),
value: this.user.currentSignInIp || this.$t("Unknown"), value: this.user.currentSignInIp || this.$t("Unknown"),
type: "code",
}, },
{ {
key: this.$i18n.t("Participations"), key: this.$i18n.t("Participations"),
value: this.user.participations.total, value: this.user.participations.total,
}, },
{
key: this.$i18n.t("Uploaded media size"),
value: formatBytes(this.user.mediaSize),
},
]; ];
} }

View File

@ -10,7 +10,11 @@
<form ref="form"> <form ref="form">
<subtitle>{{ $t("General information") }}</subtitle> <subtitle>{{ $t("General information") }}</subtitle>
<picture-upload v-model="pictureFile" :textFallback="$t('Headline picture')" /> <picture-upload
v-model="pictureFile"
:textFallback="$t('Headline picture')"
:defaultImage="event.picture"
/>
<b-field :label="$t('Title')" :type="checkTitleLength[0]" :message="checkTitleLength[1]"> <b-field :label="$t('Title')" :type="checkTitleLength[0]" :message="checkTitleLength[1]">
<b-input size="is-large" aria-required="true" required v-model="event.title" /> <b-input size="is-large" aria-required="true" required v-model="event.title" />
@ -676,6 +680,7 @@ export default class EditEvent extends Vue {
__typename: "Person", __typename: "Person",
id: organizerActor.id, id: organizerActor.id,
participations: { participations: {
__typename: "PaginatedParticipantList",
total: 1, total: 1,
elements: [ elements: [
{ {
@ -763,11 +768,13 @@ export default class EditEvent extends Vue {
res.endsOn = null; res.endsOn = null;
} }
const pictureObj = buildFileVariable(this.pictureFile, "picture"); if (this.pictureFile) {
res = { ...res, ...pictureObj }; const pictureObj = buildFileVariable(this.pictureFile, "picture");
res = { ...res, ...pictureObj };
}
try { try {
if (this.event.picture) { if (this.event.picture && this.pictureFile) {
const oldPictureFile = (await buildFileFromIPicture(this.event.picture)) as File; const oldPictureFile = (await buildFileFromIPicture(this.event.picture)) as File;
const oldPictureFileContent = await readFileAsync(oldPictureFile); const oldPictureFileContent = await readFileAsync(oldPictureFile);
const newPictureFileContent = await readFileAsync(this.pictureFile as File); const newPictureFileContent = await readFileAsync(this.pictureFile as File);

View File

@ -31,7 +31,7 @@
</li> </li>
</ul> </ul>
</nav> </nav>
<section class="container section" v-if="isCurrentActorAGroupAdmin"> <section class="container section" v-if="group && isCurrentActorAGroupAdmin">
<form @submit.prevent="updateGroup"> <form @submit.prevent="updateGroup">
<b-field :label="$t('Group name')"> <b-field :label="$t('Group name')">
<b-input v-model="group.name" /> <b-input v-model="group.name" />
@ -43,7 +43,7 @@
<picture-upload <picture-upload
:textFallback="$t('Avatar')" :textFallback="$t('Avatar')"
v-model="avatarFile" v-model="avatarFile"
:defaultImageSrc="group.avatar ? group.avatar.url : null" :defaultImage="group.avatar"
/> />
</b-field> </b-field>
@ -51,7 +51,7 @@
<picture-upload <picture-upload
:textFallback="$t('Banner')" :textFallback="$t('Banner')"
v-model="bannerFile" v-model="bannerFile"
:defaultImageSrc="group.banner ? group.banner.url : null" :defaultImage="group.banner"
/> />
</b-field> </b-field>
<p class="label">{{ $t("Group visibility") }}</p> <p class="label">{{ $t("Group visibility") }}</p>

View File

@ -12,7 +12,7 @@
<picture-upload <picture-upload
v-model="pictureFile" v-model="pictureFile"
:textFallback="$t('Headline picture')" :textFallback="$t('Headline picture')"
:defaultImageSrc="post.picture ? post.picture.url : null" :defaultImage="post.picture"
/> />
<b-field <b-field

View File

@ -6,6 +6,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.{Media, Users} alias Mobilizon.{Media, Users}
alias Mobilizon.Media.Picture alias Mobilizon.Media.Picture
alias Mobilizon.Users.User
import Mobilizon.Web.Gettext import Mobilizon.Web.Gettext
@doc """ @doc """
@ -37,8 +38,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
size: file.size size: file.size
}} }}
_error -> nil ->
{:error, dgettext("errors", "Picture with ID %{id} was not found", id: picture_id)} {:error, :not_found}
end end
end end
@ -46,7 +47,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
def upload_picture( def upload_picture(
_parent, _parent,
%{file: %Plug.Upload{} = file} = args, %{file: %Plug.Upload{} = file} = args,
%{context: %{current_user: user}} %{context: %{current_user: %User{} = user}}
) do ) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user), with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:ok, %{name: _name, url: url, content_type: content_type, size: size}} <- {:ok, %{name: _name, url: url, content_type: content_type, size: size}} <-
@ -75,7 +76,74 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
end end
end end
def upload_picture(_parent, _args, _resolution) do def upload_picture(_parent, _args, _resolution), do: {:error, :unauthenticated}
{:error, dgettext("errors", "You need to login to upload a picture")}
@doc """
Remove a picture that the user owns
"""
@spec remove_picture(map(), map(), map()) ::
{:ok, Picture.t()}
| {:error, :unauthorized}
| {:error, :unauthenticated}
| {:error, :not_found}
def remove_picture(_parent, %{id: picture_id}, %{context: %{current_user: %User{} = user}}) do
with {:picture, %Picture{actor_id: actor_id} = picture} <-
{:picture, Media.get_picture(picture_id)},
{:is_owned, %Actor{} = _actor} <- User.owns_actor(user, actor_id) do
Media.delete_picture(picture)
else
{:picture, nil} -> {:error, :not_found}
{:is_owned, _} -> {:error, :unauthorized}
end
end
def remove_picture(_parent, _args, _resolution), do: {:error, :unauthenticated}
@doc """
Return the total media size for an actor
"""
@spec actor_size(map(), map(), map()) ::
{:ok, integer()} | {:error, :unauthorized} | {:error, :unauthenticated}
def actor_size(%Actor{id: actor_id}, _args, %{
context: %{current_user: %User{} = user}
}) do
if can_get_actor_size?(user, actor_id) do
{:ok, Media.media_size_for_actor(actor_id)}
else
{:error, :unauthorized}
end
end
def actor_size(_parent, _args, _resolution), do: {:error, :unauthenticated}
@doc """
Return the total media size for a local user
"""
@spec user_size(map(), map(), map()) ::
{:ok, integer()} | {:error, :unauthorized} | {:error, :unauthenticated}
def user_size(%User{id: user_id}, _args, %{
context: %{current_user: %User{} = logged_user}
}) do
if can_get_user_size?(logged_user, user_id) do
{:ok, Media.media_size_for_user(user_id)}
else
{:error, :unauthorized}
end
end
def user_size(_parent, _args, _resolution), do: {:error, :unauthenticated}
@spec can_get_user_size?(User.t(), integer()) :: boolean()
defp can_get_actor_size?(%User{role: role} = user, actor_id) do
role in [:moderator, :administrator] || owns_actor?(User.owns_actor(user, actor_id))
end
@spec owns_actor?({:is_owned, Actor.t() | nil}) :: boolean()
defp owns_actor?({:is_owned, %Actor{} = _actor}), do: true
defp owns_actor?({:is_owned, _}), do: false
@spec can_get_user_size?(User.t(), integer()) :: boolean()
defp can_get_user_size?(%User{role: role, id: logged_user_id}, user_id) do
user_id == logged_user_id || role in [:moderator, :administrator]
end end
end end

View File

@ -525,6 +525,28 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
end end
end end
def user_medias(%User{id: user_id}, %{page: page, limit: limit}, %{
context: %{current_user: %User{id: logged_in_user_id}}
})
when user_id == logged_in_user_id do
%{elements: elements, total: total} = Mobilizon.Media.pictures_for_user(user_id, page, limit)
{:ok,
%{
elements:
Enum.map(elements, fn element ->
%{
name: element.file.name,
url: element.file.url,
id: element.id,
content_type: element.file.content_type,
size: element.file.size
}
end),
total: total
}}
end
@spec update_user_login_information(User.t(), map()) :: @spec update_user_login_information(User.t(), map()) ::
{:ok, User.t()} | {:error, Ecto.Changeset.t()} {:ok, User.t()} | {:error, Ecto.Changeset.t()}
defp update_user_login_information( defp update_user_login_information(

View File

@ -37,6 +37,8 @@ defmodule Mobilizon.GraphQL.Schema.ActorInterface do
field(:followersCount, :integer, description: "Number of followers for this actor") field(:followersCount, :integer, description: "Number of followers for this actor")
field(:followingCount, :integer, description: "Number of actors following this actor") field(:followingCount, :integer, description: "Number of actors following this actor")
field(:media_size, :integer, description: "The total size of the media from this actor")
resolve_type(fn resolve_type(fn
%Actor{type: :Person}, _ -> %Actor{type: :Person}, _ ->
:person :person

View File

@ -3,6 +3,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.ApplicationType do
Schema representation for Group. Schema representation for Group.
""" """
alias Mobilizon.GraphQL.Resolvers.Picture
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
@desc """ @desc """
@ -34,5 +35,10 @@ defmodule Mobilizon.GraphQL.Schema.Actors.ApplicationType do
field(:followers, list_of(:follower), description: "List of followers") field(:followers, list_of(:follower), description: "List of followers")
field(:followersCount, :integer, description: "Number of followers for this actor") field(:followersCount, :integer, description: "Number of followers for this actor")
field(:followingCount, :integer, description: "Number of actors following this actor") field(:followingCount, :integer, description: "Number of actors following this actor")
field(:media_size, :integer,
resolve: &Picture.actor_size/3,
description: "The total size of the media from this actor"
)
end end
end end

View File

@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1] import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Addresses alias Mobilizon.Addresses
alias Mobilizon.GraphQL.Resolvers.{Discussion, Group, Member, Post, Resource, Todos} alias Mobilizon.GraphQL.Resolvers.{Discussion, Group, Member, Picture, Post, Resource, Todos}
alias Mobilizon.GraphQL.Schema alias Mobilizon.GraphQL.Schema
import_types(Schema.Actors.MemberType) import_types(Schema.Actors.MemberType)
@ -52,6 +52,11 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
field(:followersCount, :integer, description: "Number of followers for this actor") field(:followersCount, :integer, description: "Number of followers for this actor")
field(:followingCount, :integer, description: "Number of actors following this actor") field(:followingCount, :integer, description: "Number of actors following this actor")
field(:media_size, :integer,
resolve: &Picture.actor_size/3,
description: "The total size of the media from this actor"
)
# This one should have a privacy setting # This one should have a privacy setting
field :organized_events, :paginated_event_list do field :organized_events, :paginated_event_list do
arg(:after_datetime, :datetime, arg(:after_datetime, :datetime,

View File

@ -7,7 +7,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1] import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.GraphQL.Resolvers.Person alias Mobilizon.GraphQL.Resolvers.{Person, Picture}
alias Mobilizon.GraphQL.Schema alias Mobilizon.GraphQL.Schema
import_types(Schema.Events.FeedTokenType) import_types(Schema.Events.FeedTokenType)
@ -49,6 +49,11 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
field(:followersCount, :integer, description: "Number of followers for this actor") field(:followersCount, :integer, description: "Number of followers for this actor")
field(:followingCount, :integer, description: "Number of actors following this actor") field(:followingCount, :integer, description: "Number of actors following this actor")
field(:media_size, :integer,
resolve: &Picture.actor_size/3,
description: "The total size of the media from this actor"
)
field(:feed_tokens, list_of(:feed_token), field(:feed_tokens, list_of(:feed_token),
resolve: dataloader(Events), resolve: dataloader(Events),
description: "A list of the feed tokens for this person" description: "A list of the feed tokens for this person"

View File

@ -16,6 +16,14 @@ defmodule Mobilizon.GraphQL.Schema.PictureType do
field(:size, :integer, description: "The picture's size") field(:size, :integer, description: "The picture's size")
end end
@desc """
A paginated list of pictures
"""
object :paginated_picture_list do
field(:elements, list_of(:picture), description: "The list of pictures")
field(:total, :integer, description: "The total number of pictures in the list")
end
@desc "An attached picture or a link to a picture" @desc "An attached picture or a link to a picture"
input_object :picture_input do input_object :picture_input do
# Either a full picture object # Either a full picture object
@ -35,7 +43,7 @@ defmodule Mobilizon.GraphQL.Schema.PictureType do
object :picture_queries do object :picture_queries do
@desc "Get a picture" @desc "Get a picture"
field :picture, :picture do field :picture, :picture do
arg(:id, non_null(:string), description: "The picture ID") arg(:id, non_null(:id), description: "The picture ID")
resolve(&Picture.picture/3) resolve(&Picture.picture/3)
end end
end end
@ -48,5 +56,13 @@ defmodule Mobilizon.GraphQL.Schema.PictureType do
arg(:file, non_null(:upload), description: "The picture file") arg(:file, non_null(:upload), description: "The picture file")
resolve(&Picture.upload_picture/3) resolve(&Picture.upload_picture/3)
end end
@desc """
Remove a picture
"""
field :remove_picture, :deleted_object do
arg(:id, non_null(:id), description: "The picture's ID")
resolve(&Picture.remove_picture/3)
end
end end
end end

View File

@ -26,7 +26,7 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
) )
field(:picture, :picture, field(:picture, :picture,
description: "The event's picture", description: "The posts's picture",
resolve: &Picture.picture/3 resolve: &Picture.picture/3
) )
end end

View File

@ -7,7 +7,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1] import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.GraphQL.Resolvers.User alias Mobilizon.GraphQL.Resolvers.{Picture, User}
alias Mobilizon.GraphQL.Schema alias Mobilizon.GraphQL.Schema
import_types(Schema.SortType) import_types(Schema.SortType)
@ -110,6 +110,21 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
field(:current_sign_in_ip, :string, field(:current_sign_in_ip, :string,
description: "The IP adress the user's currently signed-in with" description: "The IP adress the user's currently signed-in with"
) )
field(:media, :paginated_picture_list, description: "The user's media objects") do
arg(:page, :integer,
default_value: 1,
description: "The page in the paginated user media list"
)
arg(:limit, :integer, default_value: 10, description: "The limit of user media per page")
resolve(&User.user_medias/3)
end
field(:media_size, :integer,
resolve: &Picture.user_size/3,
description: "The total size of all the media from this user (from all their actors)"
)
end end
@desc "The list of roles an user can have" @desc "The list of roles an user can have"

View File

@ -7,8 +7,10 @@ defmodule Mobilizon.Media do
alias Ecto.Multi alias Ecto.Multi
alias Mobilizon.Actors.Actor
alias Mobilizon.Media.{File, Picture} alias Mobilizon.Media.{File, Picture}
alias Mobilizon.Storage.Repo alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.User
alias Mobilizon.Web.Upload alias Mobilizon.Web.Upload
@ -35,6 +37,52 @@ defmodule Mobilizon.Media do
|> Repo.one() |> Repo.one()
end end
@doc """
List the paginated picture for an actor
"""
@spec pictures_for_actor(integer | String.t(), integer | nil, integer | nil) :: Page.t()
def pictures_for_actor(actor_id, page, limit) do
actor_id
|> pictures_for_actor_query()
|> Page.build_page(page, limit)
end
@doc """
List the paginated picture for user
"""
@spec pictures_for_user(integer | String.t(), integer | nil, integer | nil) :: Page.t()
def pictures_for_user(user_id, page, limit) do
user_id
|> pictures_for_user_query()
|> Page.build_page(page, limit)
end
@doc """
Calculate the sum of media size used by the user
"""
@spec media_size_for_actor(integer | String.t()) :: integer()
def media_size_for_actor(actor_id) do
actor_id
|> pictures_for_actor_query()
|> select([:file])
|> Repo.all()
|> Enum.map(& &1.file.size)
|> Enum.sum()
end
@doc """
Calculate the sum of media size used by the user
"""
@spec media_size_for_user(integer | String.t()) :: integer()
def media_size_for_user(user_id) do
user_id
|> pictures_for_user_query()
|> select([:file])
|> Repo.all()
|> Enum.map(& &1.file.size)
|> Enum.sum()
end
@doc """ @doc """
Creates a picture. Creates a picture.
""" """
@ -84,4 +132,19 @@ defmodule Mobilizon.Media do
where: fragment("? @> ?", p.file, ~s|{"url": "#{url}"}|) where: fragment("? @> ?", p.file, ~s|{"url": "#{url}"}|)
) )
end end
@spec pictures_for_actor_query(integer() | String.t()) :: Ecto.Query.t()
defp pictures_for_actor_query(actor_id) do
Picture
|> join(:inner, [p], a in Actor, on: p.actor_id == a.id)
|> where([_p, a], a.id == ^actor_id)
end
@spec pictures_for_user_query(integer() | String.t()) :: Ecto.Query.t()
defp pictures_for_user_query(user_id) do
Picture
|> join(:inner, [p], a in Actor, on: p.actor_id == a.id)
|> join(:inner, [_p, a], u in User, on: a.user_id == u.id)
|> where([_p, _a, u], u.id == ^user_id)
end
end end

View File

@ -609,22 +609,22 @@ msgstr "Vous n'avez pas la permission de supprimer ce jeton"
#, elixir-format #, elixir-format
#: lib/graphql/resolvers/admin.ex:52 #: lib/graphql/resolvers/admin.ex:52
msgid "You need to be logged-in and a moderator to list action logs" msgid "You need to be logged-in and a moderator to list action logs"
msgstr "Vous devez être connecté·e pour rejoindre un groupe" msgstr "Vous devez être connecté·e et une modérateur·ice pour lister les journaux de modération"
#, elixir-format #, elixir-format
#: lib/graphql/resolvers/report.ex:26 #: lib/graphql/resolvers/report.ex:26
msgid "You need to be logged-in and a moderator to list reports" msgid "You need to be logged-in and a moderator to list reports"
msgstr "Vous devez être connecté·e pour rejoindre un groupe" msgstr "Vous devez être connecté·e et une modérateur·ice pour lister les signalements"
#, elixir-format #, elixir-format
#: lib/graphql/resolvers/report.ex:101 #: lib/graphql/resolvers/report.ex:101
msgid "You need to be logged-in and a moderator to update a report" msgid "You need to be logged-in and a moderator to update a report"
msgstr "Vous devez être connecté·e pour supprimer un groupe" msgstr "Vous devez être connecté·e et une modérateur·ice pour modifier un signalement"
#, elixir-format #, elixir-format
#: lib/graphql/resolvers/report.ex:41 #: lib/graphql/resolvers/report.ex:41
msgid "You need to be logged-in and a moderator to view a report" msgid "You need to be logged-in and a moderator to view a report"
msgstr "Vous devez être connecté·e pour rejoindre un groupe" msgstr "Vous devez être connecté·e pour et une modérateur·ice pour visionner un signalement"
#, elixir-format #, elixir-format
#: lib/graphql/resolvers/admin.ex:236 #: lib/graphql/resolvers/admin.ex:236
@ -689,7 +689,7 @@ msgstr "Vous devez être connecté·e pour supprimer un groupe"
#, elixir-format #, elixir-format
#: lib/graphql/resolvers/participant.ex:105 #: lib/graphql/resolvers/participant.ex:105
msgid "You need to be logged-in to join an event" msgid "You need to be logged-in to join an event"
msgstr "Vous devez être connecté·e pour rejoindre un groupe" msgstr "Vous devez être connecté·e pour rejoindre un événement"
#, elixir-format #, elixir-format
#: lib/graphql/resolvers/participant.ex:204 #: lib/graphql/resolvers/participant.ex:204

View File

@ -0,0 +1,31 @@
defmodule Mobilizon.Storage.Repo.Migrations.FixPictureDeletion do
use Ecto.Migration
def up do
drop_if_exists(constraint(:posts, "posts_picture_id_fkey"))
alter table(:posts) do
modify(:picture_id, references(:pictures, on_delete: :nilify_all))
end
drop_if_exists(constraint(:events, "events_picture_id_fkey"))
alter table(:events) do
modify(:picture_id, references(:pictures, on_delete: :nilify_all))
end
end
def down do
drop_if_exists(constraint(:posts, "posts_picture_id_fkey"))
alter table(:posts) do
modify(:picture_id, references(:pictures, on_delete: :delete_all))
end
drop_if_exists(constraint(:events, "events_picture_id_fkey"))
alter table(:events) do
modify(:picture_id, references(:pictures, on_delete: :delete_all))
end
end
end

View File

@ -10,6 +10,9 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
alias Mobilizon.Web.Endpoint alias Mobilizon.Web.Endpoint
@default_picture_details %{name: "my pic", alt: "represents something", file: "picture.png"}
@default_picture_path "test/fixtures/picture.png"
setup %{conn: conn} do setup %{conn: conn} do
user = insert(:user) user = insert(:user)
actor = insert(:actor, user: user) actor = insert(:actor, user: user)
@ -17,53 +20,59 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
{:ok, conn: conn, user: user, actor: actor} {:ok, conn: conn, user: user, actor: actor}
end end
@picture_query """
query Picture($id: ID!) {
picture(id: $id) {
id
name,
alt,
url,
content_type,
size
}
}
"""
@upload_picture_mutation """
mutation UploadPicture($name: String!, $alt: String, $file: Upload!) {
uploadPicture(
name: $name
alt: $alt
file: $file
) {
url
name
content_type
size
}
}
"""
describe "Resolver: Get picture" do describe "Resolver: Get picture" do
test "picture/3 returns the information on a picture", context do test "picture/3 returns the information on a picture", %{conn: conn} do
%Picture{id: id} = picture = insert(:picture) %Picture{id: id} = picture = insert(:picture)
query = """
{
picture(id: "#{id}") {
name,
alt,
url,
content_type,
size
}
}
"""
res = res =
context.conn conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "picture")) |> AbsintheHelpers.graphql_query(query: @picture_query, variables: %{id: id})
assert json_response(res, 200)["data"]["picture"]["name"] == picture.file.name assert res["data"]["picture"]["name"] == picture.file.name
assert json_response(res, 200)["data"]["picture"]["content_type"] == assert res["data"]["picture"]["content_type"] ==
picture.file.content_type picture.file.content_type
assert json_response(res, 200)["data"]["picture"]["size"] == 13_120 assert res["data"]["picture"]["size"] == 13_120
assert json_response(res, 200)["data"]["picture"]["url"] =~ Endpoint.url() assert res["data"]["picture"]["url"] =~ Endpoint.url()
end end
test "picture/3 returns nothing on a non-existent picture", context do test "picture/3 returns nothing on a non-existent picture", %{conn: conn} do
query = """
{
picture(id: "3") {
name,
alt,
url
}
}
"""
res = res =
context.conn conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "picture")) |> AbsintheHelpers.graphql_query(query: @picture_query, variables: %{id: 3})
assert hd(json_response(res, 200)["errors"])["message"] == assert hd(res["errors"])["message"] == "Resource not found"
"Picture with ID 3 was not found" assert hd(res["errors"])["status_code"] == 404
end end
end end
@ -71,22 +80,9 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
test "upload_picture/3 uploads a new picture", %{conn: conn, user: user} do test "upload_picture/3 uploads a new picture", %{conn: conn, user: user} do
picture = %{name: "my pic", alt: "represents something", file: "picture.png"} picture = %{name: "my pic", alt: "represents something", file: "picture.png"}
mutation = """
mutation { uploadPicture(
name: "#{picture.name}",
alt: "#{picture.alt}",
file: "#{picture.file}"
) {
url,
name,
content_type,
size
}
}
"""
map = %{ map = %{
"query" => mutation, "query" => @upload_picture_mutation,
"variables" => picture,
picture.file => %Plug.Upload{ picture.file => %Plug.Upload{
path: "test/fixtures/picture.png", path: "test/fixtures/picture.png",
filename: picture.file filename: picture.file
@ -101,30 +97,20 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
"/api", "/api",
map map
) )
|> json_response(200)
assert json_response(res, 200)["data"]["uploadPicture"]["name"] == picture.name assert res["data"]["uploadPicture"]["name"] == picture.name
assert json_response(res, 200)["data"]["uploadPicture"]["content_type"] == "image/png" assert res["data"]["uploadPicture"]["content_type"] == "image/png"
assert json_response(res, 200)["data"]["uploadPicture"]["size"] == 10_097 assert res["data"]["uploadPicture"]["size"] == 10_097
assert json_response(res, 200)["data"]["uploadPicture"]["url"] assert res["data"]["uploadPicture"]["url"]
end end
test "upload_picture/3 forbids uploading if no auth", %{conn: conn} do test "upload_picture/3 forbids uploading if no auth", %{conn: conn} do
picture = %{name: "my pic", alt: "represents something", file: "picture.png"} picture = %{name: "my pic", alt: "represents something", file: "picture.png"}
mutation = """
mutation { uploadPicture(
name: "#{picture.name}",
alt: "#{picture.alt}",
file: "#{picture.file}"
) {
url,
name
}
}
"""
map = %{ map = %{
"query" => mutation, "query" => @upload_picture_mutation,
"variables" => picture,
picture.file => %Plug.Upload{ picture.file => %Plug.Upload{
path: "test/fixtures/picture.png", path: "test/fixtures/picture.png",
filename: picture.file filename: picture.file
@ -138,9 +124,368 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
"/api", "/api",
map map
) )
|> json_response(200)
assert hd(json_response(res, 200)["errors"])["message"] == assert hd(res["errors"])["message"] == "You need to be logged in"
"You need to login to upload a picture"
end end
end end
describe "Resolver: Remove picture" do
@remove_picture_mutation """
mutation RemovePicture($id: ID!) {
removePicture(id: $id) {
id
}
}
"""
test "Removes a previously uploaded picture", %{conn: conn, user: user, actor: actor} do
%Picture{id: picture_id} = insert(:picture, actor: actor)
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @remove_picture_mutation,
variables: %{id: picture_id}
)
assert is_nil(res["errors"])
assert res["data"]["removePicture"]["id"] == to_string(picture_id)
res =
conn
|> AbsintheHelpers.graphql_query(query: @picture_query, variables: %{id: picture_id})
assert hd(res["errors"])["message"] == "Resource not found"
assert hd(res["errors"])["status_code"] == 404
end
test "Removes nothing if picture is not found", %{conn: conn, user: user} do
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @remove_picture_mutation,
variables: %{id: 400}
)
assert hd(res["errors"])["message"] == "Resource not found"
assert hd(res["errors"])["status_code"] == 404
end
test "Removes nothing if picture if not logged-in", %{conn: conn, actor: actor} do
%Picture{id: picture_id} = insert(:picture, actor: actor)
res =
conn
|> AbsintheHelpers.graphql_query(
query: @remove_picture_mutation,
variables: %{id: picture_id}
)
assert hd(res["errors"])["message"] == "You need to be logged in"
assert hd(res["errors"])["status_code"] == 401
end
end
describe "Resolver: Get actor media size" do
@actor_media_size_query """
query LoggedPerson {
loggedPerson {
id
mediaSize
}
}
"""
test "with own actor", %{conn: conn} do
user = insert(:user)
insert(:actor, user: user)
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(query: @actor_media_size_query)
assert res["data"]["loggedPerson"]["mediaSize"] == 0
res = upload_picture(conn, user)
assert res["data"]["uploadPicture"]["size"] == 10_097
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(query: @actor_media_size_query)
assert res["data"]["loggedPerson"]["mediaSize"] == 10_097
res =
upload_picture(
conn,
user,
"test/fixtures/image.jpg",
Map.put(@default_picture_details, :file, "image.jpg")
)
assert res["data"]["uploadPicture"]["size"] == 13_227
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(query: @actor_media_size_query)
assert res["data"]["loggedPerson"]["mediaSize"] == 23_324
end
@list_actors_query """
query ListPersons($preferredUsername: String) {
persons(preferredUsername: $preferredUsername) {
total,
elements {
id
mediaSize
}
}
}
"""
test "as a moderator", %{conn: conn} do
moderator = insert(:user, role: :moderator)
user = insert(:user)
actor = insert(:actor, user: user)
res =
conn
|> auth_conn(moderator)
|> AbsintheHelpers.graphql_query(
query: @list_actors_query,
variables: %{preferredUsername: actor.preferred_username}
)
assert is_nil(res["errors"])
assert hd(res["data"]["persons"]["elements"])["mediaSize"] == 0
upload_picture(conn, user)
res =
conn
|> auth_conn(moderator)
|> AbsintheHelpers.graphql_query(
query: @list_actors_query,
variables: %{preferredUsername: actor.preferred_username}
)
assert is_nil(res["errors"])
assert hd(res["data"]["persons"]["elements"])["mediaSize"] == 10_097
end
@event_organizer_media_query """
query Event($uuid: UUID!) {
event(uuid: $uuid) {
id
organizerActor {
id
mediaSize
}
}
}
"""
test "as a different user", %{conn: conn} do
user = insert(:user)
event = insert(:event)
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @event_organizer_media_query,
variables: %{uuid: event.uuid}
)
assert hd(res["errors"])["message"] == "unauthorized"
end
test "without being logged-in", %{conn: conn} do
event = insert(:event)
res =
conn
|> AbsintheHelpers.graphql_query(
query: @event_organizer_media_query,
variables: %{uuid: event.uuid}
)
assert hd(res["errors"])["message"] == "unauthenticated"
end
end
describe "Resolver: Get user media size" do
@user_media_size_query """
query LoggedUser {
loggedUser {
id
mediaSize
}
}
"""
@change_default_actor_mutation """
mutation ChangeDefaultActor($preferredUsername: String!) {
changeDefaultActor(preferredUsername: $preferredUsername) {
defaultActor {
id
preferredUsername
}
}
}
"""
test "with own user", %{conn: conn} do
user = insert(:user)
insert(:actor, user: user)
actor_2 = insert(:actor, user: user)
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(query: @user_media_size_query)
assert res["errors"] == nil
assert res["data"]["loggedUser"]["mediaSize"] == 0
res = upload_picture(conn, user)
assert res["data"]["uploadPicture"]["size"] == 10_097
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(query: @user_media_size_query)
assert res["data"]["loggedUser"]["mediaSize"] == 10_097
res =
upload_picture(
conn,
user,
"test/fixtures/image.jpg",
Map.put(@default_picture_details, :file, "image.jpg")
)
assert res["data"]["uploadPicture"]["size"] == 13_227
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(query: @user_media_size_query)
assert res["data"]["loggedUser"]["mediaSize"] == 23_324
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @change_default_actor_mutation,
variables: %{preferredUsername: actor_2.preferred_username}
)
assert is_nil(res["errors"])
res =
upload_picture(
conn,
user,
"test/fixtures/image.jpg",
Map.put(@default_picture_details, :file, "image.jpg")
)
assert res["data"]["uploadPicture"]["size"] == 13_227
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(query: @user_media_size_query)
assert res["data"]["loggedUser"]["mediaSize"] == 36_551
end
@list_users_query """
query ListUsers($email: String) {
users(email: $email) {
total,
elements {
id
mediaSize
}
}
}
"""
test "as a moderator", %{conn: conn} do
moderator = insert(:user, role: :moderator)
user = insert(:user)
insert(:actor, user: user)
res =
conn
|> auth_conn(moderator)
|> AbsintheHelpers.graphql_query(
query: @list_users_query,
variables: %{email: user.email}
)
assert is_nil(res["errors"])
assert hd(res["data"]["users"]["elements"])["mediaSize"] == 0
res = upload_picture(conn, user)
assert is_nil(res["errors"])
assert res["data"]["uploadPicture"]["size"] == 10_097
res =
conn
|> auth_conn(moderator)
|> AbsintheHelpers.graphql_query(
query: @list_users_query,
variables: %{email: user.email}
)
assert is_nil(res["errors"])
assert hd(res["data"]["users"]["elements"])["mediaSize"] == 10_097
end
test "without being logged-in", %{conn: conn} do
res =
conn
|> AbsintheHelpers.graphql_query(query: @user_media_size_query)
assert hd(res["errors"])["message"] == "You need to be logged-in to view current user"
end
end
@spec upload_picture(Plug.Conn.t(), Mobilizon.Users.User.t(), String.t(), map()) :: map()
defp upload_picture(
conn,
user,
picture_path \\ @default_picture_path,
picture_details \\ @default_picture_details
) do
map = %{
"query" => @upload_picture_mutation,
"variables" => picture_details,
picture_details.file => %Plug.Upload{
path: picture_path,
filename: picture_details.file
}
}
conn
|> auth_conn(user)
|> put_req_header("content-type", "multipart/form-data")
|> post(
"/api",
map
)
|> json_response(200)
end
end end