Merge remote-tracking branch 'origin/main'

This commit is contained in:
778a69cd 2023-09-02 14:48:50 +02:00
commit 25836b5971
32 changed files with 385 additions and 31 deletions

View file

@ -132,7 +132,9 @@ exunit:
variables: variables:
MIX_ENV: test MIX_ENV: test
before_script: before_script:
- mix deps.get && mix tz_world.update - mix deps.get
- mix compile
- mix tz_world.update
- mix ecto.create - mix ecto.create
- mix ecto.migrate - mix ecto.migrate
script: script:

View file

@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 3.2.0-beta.2 (2023-09-01)
Fixes a CI issue that prevented 3.2.0-beta.2 being released.
### Bug Fixes
* **front:** fix behavior of local toggle for profiles & groups view depending on domain value ([84f62cd](https://framagit.org/framasoft/mobilizon/commit/84f62cd043d5cf5d186fea6f24a1a9dff5fc64ce))
## 3.2.0-beta.1 (2023-09-01) ## 3.2.0-beta.1 (2023-09-01)
### Features ### Features

View file

@ -41,7 +41,10 @@ config :mobilizon, :instance,
email_reply_to: "noreply@localhost" email_reply_to: "noreply@localhost"
config :mobilizon, :groups, enabled: true config :mobilizon, :groups, enabled: true
config :mobilizon, :events, creation: true
config :mobilizon, :events,
creation: true,
external: true
config :mobilizon, :restrictions, only_admin_can_create_groups: false config :mobilizon, :restrictions, only_admin_can_create_groups: false
config :mobilizon, :restrictions, only_groups_can_create_events: false config :mobilizon, :restrictions, only_groups_can_create_events: false

View file

@ -1,6 +1,6 @@
{ {
"name": "mobilizon", "name": "mobilizon",
"version": "3.2.0-beta.1", "version": "3.2.0-beta.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View file

@ -1,7 +1,13 @@
<template> <template>
<div class=""> <div class="">
<external-participation-button
v-if="event && event.joinOptions === EventJoinOptions.EXTERNAL"
:event="event"
:current-actor="currentActor"
/>
<participation-section <participation-section
v-if="event && anonymousParticipationConfig" v-else-if="event && anonymousParticipationConfig"
:participation="participations[0]" :participation="participations[0]"
:event="event" :event="event"
:anonymousParticipation="anonymousParticipation" :anonymousParticipation="anonymousParticipation"
@ -15,7 +21,10 @@
@cancel-anonymous-participation="cancelAnonymousParticipation" @cancel-anonymous-participation="cancelAnonymousParticipation"
/> />
<div class="flex flex-col gap-1 mt-1"> <div class="flex flex-col gap-1 mt-1">
<p class="inline-flex gap-2 ml-auto"> <p
class="inline-flex gap-2 ml-auto"
v-if="event.joinOptions !== EventJoinOptions.EXTERNAL"
>
<TicketConfirmationOutline /> <TicketConfirmationOutline />
<router-link <router-link
class="participations-link" class="participations-link"
@ -349,6 +358,7 @@ import { useMutation } from "@vue/apollo-composable";
import { useCreateReport } from "@/composition/apollo/report"; import { useCreateReport } from "@/composition/apollo/report";
import { useDeleteEvent } from "@/composition/apollo/event"; import { useDeleteEvent } from "@/composition/apollo/event";
import { useProgrammatic } from "@oruga-ui/oruga-next"; import { useProgrammatic } from "@oruga-ui/oruga-next";
import ExternalParticipationButton from "./ExternalParticipationButton.vue";
const ShareEventModal = defineAsyncComponent( const ShareEventModal = defineAsyncComponent(
() => import("@/components/Event/ShareEventModal.vue") () => import("@/components/Event/ShareEventModal.vue")

View file

@ -0,0 +1,30 @@
<template>
<o-button
tag="a"
:href="
event.externalParticipationUrl
? encodeURI(`${event.externalParticipationUrl}?uuid=${event.uuid}`)
: '#'
"
rel="noopener ugc"
target="_blank"
:disabled="!event.externalParticipationUrl"
icon-right="OpenInNew"
>
{{ t("Go to booking") }}
</o-button>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { IEvent } from "../../types/event.model";
import { useI18n } from "vue-i18n";
const { t } = useI18n({ useScope: "global" });
const props = defineProps<{
event: IEvent;
}>();
const event = computed(() => props.event);
</script>

View file

@ -72,6 +72,7 @@ export const CONFIG = gql`
features { features {
groups groups
eventCreation eventCreation
eventExternal
antispam antispam
} }
restrictions { restrictions {
@ -370,6 +371,7 @@ export const FEATURES = gql`
features { features {
groups groups
eventCreation eventCreation
eventExternal
antispam antispam
} }
} }

View file

@ -21,6 +21,7 @@ const FULL_EVENT_FRAGMENT = gql`
status status
visibility visibility
joinOptions joinOptions
externalParticipationUrl
draft draft
language language
category category
@ -121,6 +122,7 @@ export const FETCH_EVENT_BASIC = gql`
id id
uuid uuid
joinOptions joinOptions
externalParticipationUrl
participantStats { participantStats {
going going
notApproved notApproved
@ -199,6 +201,7 @@ export const CREATE_EVENT = gql`
$status: EventStatus $status: EventStatus
$visibility: EventVisibility $visibility: EventVisibility
$joinOptions: EventJoinOptions $joinOptions: EventJoinOptions
$externalParticipationUrl: String
$draft: Boolean $draft: Boolean
$tags: [String] $tags: [String]
$picture: MediaInput $picture: MediaInput
@ -220,6 +223,7 @@ export const CREATE_EVENT = gql`
status: $status status: $status
visibility: $visibility visibility: $visibility
joinOptions: $joinOptions joinOptions: $joinOptions
externalParticipationUrl: $externalParticipationUrl
draft: $draft draft: $draft
tags: $tags tags: $tags
picture: $picture picture: $picture
@ -247,6 +251,7 @@ export const EDIT_EVENT = gql`
$status: EventStatus $status: EventStatus
$visibility: EventVisibility $visibility: EventVisibility
$joinOptions: EventJoinOptions $joinOptions: EventJoinOptions
$externalParticipationUrl: String
$draft: Boolean $draft: Boolean
$tags: [String] $tags: [String]
$picture: MediaInput $picture: MediaInput
@ -269,6 +274,7 @@ export const EDIT_EVENT = gql`
status: $status status: $status
visibility: $visibility visibility: $visibility
joinOptions: $joinOptions joinOptions: $joinOptions
externalParticipationUrl: $externalParticipationUrl
draft: $draft draft: $draft
tags: $tags tags: $tags
picture: $picture picture: $picture

View file

@ -295,6 +295,10 @@ export const CREATE_GROUP = gql`
$summary: String $summary: String
$avatar: MediaInput $avatar: MediaInput
$banner: MediaInput $banner: MediaInput
$physicalAddress: AddressInput
$visibility: GroupVisibility
$openness: Openness
$manuallyApprovesFollowers: Boolean
) { ) {
createGroup( createGroup(
preferredUsername: $preferredUsername preferredUsername: $preferredUsername
@ -302,6 +306,10 @@ export const CREATE_GROUP = gql`
summary: $summary summary: $summary
banner: $banner banner: $banner
avatar: $avatar avatar: $avatar
physicalAddress: $physicalAddress
visibility: $visibility
openness: $openness
manuallyApprovesFollowers: $manuallyApprovesFollowers
) { ) {
...ActorFragment ...ActorFragment
banner { banner {

View file

@ -1605,5 +1605,10 @@
"Reported by an unknown actor": "Reported by an unknown actor", "Reported by an unknown actor": "Reported by an unknown actor",
"Reported at": "Reported at", "Reported at": "Reported at",
"Updated at": "Updated at", "Updated at": "Updated at",
"Suspend the profile?": "Suspend the profile?" "Suspend the profile?": "Suspend the profile?",
"Go to booking": "Go to booking",
"External registration": "External registration",
"I want to manage the registration with an external provider": "I want to manage the registration with an external provider",
"External provider URL": "External provider URL",
"Members will also access private sections like discussions, resources and restricted posts.": "Members will also access private sections like discussions, resources and restricted posts."
} }

View file

@ -1601,5 +1601,10 @@
"{username} was invited to {group}": "{username} a été invité à {group}", "{username} was invited to {group}": "{username} a été invité à {group}",
"{user}'s follow request was accepted": "La demande de suivi de {user} a été acceptée", "{user}'s follow request was accepted": "La demande de suivi de {user} a été acceptée",
"{user}'s follow request was rejected": "La demande de suivi de {user} a été rejetée", "{user}'s follow request was rejected": "La demande de suivi de {user} a été rejetée",
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap" "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
"Go to booking": "Aller à la réservation",
"External registration": "Inscription externe",
"I want to manage the registration with an external provider": "Je souhaite gérer l'enregistrement auprès d'un fournisseur externe",
"External provider URL": "URL du fournisseur externe",
"Members will also access private sections like discussions, resources and restricted posts.": "Les membres auront également accès aux section privées comme les discussions, les ressources et les billets restreints."
} }

View file

@ -5,8 +5,7 @@ import type { IResource } from "../resource";
import type { IEvent } from "../event.model"; import type { IEvent } from "../event.model";
import type { IDiscussion } from "../discussions"; import type { IDiscussion } from "../discussions";
import type { IPost } from "../post.model"; import type { IPost } from "../post.model";
import type { IAddress } from "../address.model"; import { Address, type IAddress } from "../address.model";
import { Address } from "../address.model";
import { ActorType, GroupVisibility, Openness } from "../enums"; import { ActorType, GroupVisibility, Openness } from "../enums";
import type { IMember } from "./member.model"; import type { IMember } from "./member.model";
import type { ITodoList } from "../todolist"; import type { ITodoList } from "../todolist";
@ -53,11 +52,11 @@ export class Group extends Actor implements IGroup {
visibility: GroupVisibility = GroupVisibility.PUBLIC; visibility: GroupVisibility = GroupVisibility.PUBLIC;
activity: Paginate<IActivity> = { elements: [], total: 0 }; activity: Paginate<IActivity> = { elements: [], total: 0 };
openness: Openness = Openness.INVITE_ONLY; openness: Openness = Openness.MODERATED;
physicalAddress: IAddress = new Address(); physicalAddress: IAddress = new Address();
manuallyApprovesFollowers = true; manuallyApprovesFollowers = false;
patch(hash: IGroup | Record<string, unknown>): void { patch(hash: IGroup | Record<string, unknown>): void {
Object.assign(this, hash); Object.assign(this, hash);

View file

@ -96,6 +96,7 @@ export interface IConfig {
timezones: string[]; timezones: string[];
features: { features: {
eventCreation: boolean; eventCreation: boolean;
eventExternal: boolean;
groups: boolean; groups: boolean;
antispam: boolean; antispam: boolean;
}; };

View file

@ -64,6 +64,7 @@ export enum EventJoinOptions {
FREE = "FREE", FREE = "FREE",
RESTRICTED = "RESTRICTED", RESTRICTED = "RESTRICTED",
INVITE = "INVITE", INVITE = "INVITE",
EXTERNAL = "EXTERNAL",
} }
export enum EventVisibilityJoinOptions { export enum EventVisibilityJoinOptions {

View file

@ -43,6 +43,7 @@ interface IEventEditJSON {
status: EventStatus; status: EventStatus;
visibility: EventVisibility; visibility: EventVisibility;
joinOptions: EventJoinOptions; joinOptions: EventJoinOptions;
externalParticipationUrl: string | null;
draft: boolean; draft: boolean;
picture?: IMedia | { mediaId: string } | null; picture?: IMedia | { mediaId: string } | null;
attributedToId: string | null; attributedToId: string | null;
@ -72,6 +73,7 @@ export interface IEvent {
status: EventStatus; status: EventStatus;
visibility: EventVisibility; visibility: EventVisibility;
joinOptions: EventJoinOptions; joinOptions: EventJoinOptions;
externalParticipationUrl: string | null;
draft: boolean; draft: boolean;
picture: IMedia | null; picture: IMedia | null;
@ -132,6 +134,8 @@ export class EventModel implements IEvent {
joinOptions = EventJoinOptions.FREE; joinOptions = EventJoinOptions.FREE;
externalParticipationUrl: string | null = null;
status = EventStatus.CONFIRMED; status = EventStatus.CONFIRMED;
draft = true; draft = true;
@ -197,6 +201,7 @@ export class EventModel implements IEvent {
this.status = hash.status; this.status = hash.status;
this.visibility = hash.visibility; this.visibility = hash.visibility;
this.joinOptions = hash.joinOptions; this.joinOptions = hash.joinOptions;
this.externalParticipationUrl = hash.externalParticipationUrl;
this.draft = hash.draft; this.draft = hash.draft;
this.picture = hash.picture; this.picture = hash.picture;
@ -248,6 +253,7 @@ export function toEditJSON(event: IEditableEvent): IEventEditJSON {
category: event.category, category: event.category,
visibility: event.visibility, visibility: event.visibility,
joinOptions: event.joinOptions, joinOptions: event.joinOptions,
externalParticipationUrl: event.externalParticipationUrl,
draft: event.draft, draft: event.draft,
tags: event.tags.map((t) => t.title), tags: event.tags.map((t) => t.title),
onlineAddress: event.onlineAddress, onlineAddress: event.onlineAddress,

View file

@ -120,7 +120,7 @@ import {
} from "vue-use-route-query"; } from "vue-use-route-query";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head"; import { useHead } from "@vueuse/head";
import { computed, ref } from "vue"; import { computed } from "vue";
import { Paginate } from "@/types/paginate"; import { Paginate } from "@/types/paginate";
import { IGroup } from "@/types/actor"; import { IGroup } from "@/types/actor";
import AccountGroup from "vue-material-design-icons/AccountGroup.vue"; import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
@ -129,11 +129,11 @@ const PROFILES_PER_PAGE = 10;
const { restrictions } = useRestrictions(); const { restrictions } = useRestrictions();
const preferredUsername = ref(""); const preferredUsername = useRouteQuery("preferredUsername", "");
const name = ref(""); const name = useRouteQuery("name", "");
const domain = ref(""); const domain = useRouteQuery("domain", "");
const local = useRouteQuery("local", true, booleanTransformer); const local = useRouteQuery("local", domain.value === "", booleanTransformer);
const suspended = useRouteQuery("suspended", false, booleanTransformer); const suspended = useRouteQuery("suspended", false, booleanTransformer);
const page = useRouteQuery("page", 1, integerTransformer); const page = useRouteQuery("page", 1, integerTransformer);

View file

@ -119,7 +119,7 @@ const preferredUsername = useRouteQuery("preferredUsername", "");
const name = useRouteQuery("name", ""); const name = useRouteQuery("name", "");
const domain = useRouteQuery("domain", ""); const domain = useRouteQuery("domain", "");
const local = useRouteQuery("local", true, booleanTransformer); const local = useRouteQuery("local", domain.value === "", booleanTransformer);
const suspended = useRouteQuery("suspended", false, booleanTransformer); const suspended = useRouteQuery("suspended", false, booleanTransformer);
const page = useRouteQuery("page", 1, integerTransformer); const page = useRouteQuery("page", 1, integerTransformer);

View file

@ -247,7 +247,28 @@
</div>--> </div>-->
<o-field <o-field
v-if="anonymousParticipationConfig?.allowed" :label="t('External registration')"
v-if="features?.eventExternal"
>
<o-switch v-model="externalParticipation">
{{
t("I want to manage the registration with an external provider")
}}
</o-switch>
</o-field>
<o-field v-if="externalParticipation" :label="t('URL')">
<o-input
icon="link"
type="url"
v-model="event.externalParticipationUrl"
:placeholder="t('External provider URL')"
required
/>
</o-field>
<o-field
v-if="anonymousParticipationConfig?.allowed && !externalParticipation"
:label="t('Anonymous participations')" :label="t('Anonymous participations')"
> >
<o-switch v-model="eventOptions.anonymousParticipation"> <o-switch v-model="eventOptions.anonymousParticipation">
@ -268,19 +289,22 @@
</o-switch> </o-switch>
</o-field> </o-field>
<o-field :label="t('Participation approval')"> <o-field
:label="t('Participation approval')"
v-show="!externalParticipation"
>
<o-switch v-model="needsApproval">{{ <o-switch v-model="needsApproval">{{
t("I want to approve every participation request") t("I want to approve every participation request")
}}</o-switch> }}</o-switch>
</o-field> </o-field>
<o-field :label="t('Number of places')"> <o-field :label="t('Number of places')" v-show="!externalParticipation">
<o-switch v-model="limitedPlaces">{{ <o-switch v-model="limitedPlaces">{{
t("Limited number of places") t("Limited number of places")
}}</o-switch> }}</o-switch>
</o-field> </o-field>
<div class="" v-if="limitedPlaces"> <div class="" v-if="limitedPlaces && !externalParticipation">
<o-field :label="t('Number of places')" label-for="number-of-places"> <o-field :label="t('Number of places')" label-for="number-of-places">
<o-input <o-input
type="number" type="number"
@ -1308,6 +1332,19 @@ const orderedCategories = computed(() => {
if (!eventCategories.value) return undefined; if (!eventCategories.value) return undefined;
return sortBy(eventCategories.value, ["label"]); return sortBy(eventCategories.value, ["label"]);
}); });
const externalParticipation = computed({
get() {
return event.value?.joinOptions === EventJoinOptions.EXTERNAL;
},
set(newValue) {
if (newValue === true) {
event.value.joinOptions = EventJoinOptions.EXTERNAL;
} else {
event.value.joinOptions = EventJoinOptions.FREE;
}
},
});
</script> </script>
<style lang="scss"> <style lang="scss">

View file

@ -69,11 +69,25 @@
:message="summaryErrors[0]" :message="summaryErrors[0]"
:type="summaryErrors[1]" :type="summaryErrors[1]"
> >
<o-input v-model="group.summary" type="textarea" id="group-summary" /> <editor
v-if="currentActor"
id="group-summary"
mode="basic"
class="mb-3"
v-model="group.summary"
:maxSize="500"
:aria-label="$t('Group description body')"
:current-actor="currentActor"
/>
</o-field> </o-field>
<div> <full-address-auto-complete
<b>{{ t("Avatar") }}</b> :label="$t('Group address')"
v-model="group.physicalAddress"
/>
<div class="field">
<b class="field-label">{{ t("Avatar") }}</b>
<picture-upload <picture-upload
:textFallback="t('Avatar')" :textFallback="t('Avatar')"
v-model="avatarFile" v-model="avatarFile"
@ -81,8 +95,8 @@
/> />
</div> </div>
<div> <div class="field">
<b>{{ t("Banner") }}</b> <b class="field-label">{{ t("Banner") }}</b>
<picture-upload <picture-upload
:textFallback="t('Banner')" :textFallback="t('Banner')"
v-model="bannerFile" v-model="bannerFile"
@ -90,7 +104,101 @@
/> />
</div> </div>
<o-button variant="primary" native-type="submit"> <fieldset>
<legend class="field-label !mb-0 mt-2">
{{ t("Group visibility") }}
</legend>
<o-radio
v-model="group.visibility"
name="groupVisibility"
:native-value="GroupVisibility.PUBLIC"
>
{{ $t("Visible everywhere on the web") }}<br />
<small>{{
$t(
"The group will be publicly listed in search results and may be suggested in the explore section. Only public informations will be shown on it's page."
)
}}</small>
</o-radio>
<o-radio
v-model="group.visibility"
name="groupVisibility"
:native-value="GroupVisibility.UNLISTED"
>{{ $t("Only accessible through link") }}<br />
<small>{{
$t(
"You'll need to transmit the group URL so people may access the group's profile. The group won't be findable in Mobilizon's search or regular search engines."
)
}}</small>
</o-radio>
</fieldset>
<fieldset>
<legend class="mt-2">
<span class="field-label !mb-0">{{ t("New members") }} </span>
<span>
{{
t(
"Members will also access private sections like discussions, resources and restricted posts."
)
}}
</span>
</legend>
<o-field>
<o-radio
v-model="group.openness"
name="groupOpenness"
:native-value="Openness.OPEN"
>
{{ $t("Anyone can join freely") }}<br />
<small>{{
$t(
"Anyone wanting to be a member from your group will be able to from your group page."
)
}}</small>
</o-radio>
</o-field>
<o-field>
<o-radio
v-model="group.openness"
name="groupOpenness"
:native-value="Openness.MODERATED"
>{{ $t("Moderate new members") }}<br />
<small>{{
$t(
"Anyone can request being a member, but an administrator needs to approve the membership."
)
}}</small>
</o-radio>
</o-field>
<o-field>
<o-radio
v-model="group.openness"
name="groupOpenness"
:native-value="Openness.INVITE_ONLY"
>{{ $t("Manually invite new members") }}<br />
<small>{{
$t(
"The only way for your group to get new members is if an admininistrator invites them."
)
}}</small>
</o-radio>
</o-field>
</fieldset>
<fieldset>
<legend class="mt-2">
<span class="field-label !mb-0">
{{ t("Followers") }}
</span>
<span>
{{ t("Followers will receive new public events and posts.") }}
</span>
</legend>
<o-checkbox v-model="group.manuallyApprovesFollowers">
{{ t("Manually approve new followers") }}
</o-checkbox>
</fieldset>
<o-button variant="primary" native-type="submit" class="mt-3">
{{ t("Create my group") }} {{ t("Create my group") }}
</o-button> </o-button>
</form> </form>
@ -105,7 +213,14 @@ import PictureUpload from "../../components/PictureUpload.vue";
import { ErrorResponse } from "@/types/errors.model"; import { ErrorResponse } from "@/types/errors.model";
import { ServerParseError } from "@apollo/client/link/http"; import { ServerParseError } from "@apollo/client/link/http";
import { useCurrentActorClient } from "@/composition/apollo/actor"; import { useCurrentActorClient } from "@/composition/apollo/actor";
import { computed, inject, reactive, ref, watch } from "vue"; import {
computed,
defineAsyncComponent,
inject,
reactive,
ref,
watch,
} from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useCreateGroup } from "@/composition/apollo/group"; import { useCreateGroup } from "@/composition/apollo/group";
@ -116,6 +231,12 @@ import {
} from "@/composition/config"; } from "@/composition/config";
import { Notifier } from "@/plugins/notifier"; import { Notifier } from "@/plugins/notifier";
import { useHead } from "@vueuse/head"; import { useHead } from "@vueuse/head";
import { Openness, GroupVisibility } from "@/types/enums";
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
const Editor = defineAsyncComponent(
() => import("@/components/TextEditor.vue")
);
const { currentActor } = useCurrentActorClient(); const { currentActor } = useCurrentActorClient();
@ -156,10 +277,20 @@ const buildVariables = computed(() => {
let avatarObj = {}; let avatarObj = {};
let bannerObj = {}; let bannerObj = {};
const cloneGroup = group.value;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete cloneGroup.physicalAddress.__typename;
delete cloneGroup.physicalAddress.pictureInfo;
const groupBasic = { const groupBasic = {
preferredUsername: group.value.preferredUsername, preferredUsername: group.value.preferredUsername,
name: group.value.name, name: group.value.name,
summary: group.value.summary, summary: group.value.summary,
physicalAddress: cloneGroup.physicalAddress,
visibility: group.value.visibility,
openness: group.value.openness,
manuallyApprovesFollowers: group.value.manuallyApprovesFollowers,
}; };
if (avatarFile.value) { if (avatarFile.value) {

View file

@ -78,6 +78,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
visibility: visibility, visibility: visibility,
join_options: Map.get(object, "joinMode", "free"), join_options: Map.get(object, "joinMode", "free"),
local: is_local?(object["id"]), local: is_local?(object["id"]),
external_participation_url: object["externalParticipationUrl"],
options: options, options: options,
metadata: metadata, metadata: metadata,
status: object |> Map.get("ical:status", "CONFIRMED") |> String.downcase(), status: object |> Map.get("ical:status", "CONFIRMED") |> String.downcase(),
@ -129,6 +130,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
"mediaType" => "text/html", "mediaType" => "text/html",
"startTime" => event.begins_on |> shift_tz(event.options.timezone) |> date_to_string(), "startTime" => event.begins_on |> shift_tz(event.options.timezone) |> date_to_string(),
"joinMode" => to_string(event.join_options), "joinMode" => to_string(event.join_options),
"externalParticipationUrl" => event.external_participation_url,
"endTime" => event.ends_on |> shift_tz(event.options.timezone) |> date_to_string(), "endTime" => event.ends_on |> shift_tz(event.options.timezone) |> date_to_string(),
"tag" => event.tags |> build_tags(), "tag" => event.tags |> build_tags(),
"maximumAttendeeCapacity" => event.options.maximum_attendee_capacity, "maximumAttendeeCapacity" => event.options.maximum_attendee_capacity,

View file

@ -145,6 +145,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
features: %{ features: %{
groups: Config.instance_group_feature_enabled?(), groups: Config.instance_group_feature_enabled?(),
event_creation: Config.instance_event_creation_enabled?(), event_creation: Config.instance_event_creation_enabled?(),
event_external: Config.instance_event_external_enabled?(),
antispam: AntiSpam.service().ready?() antispam: AntiSpam.service().ready?()
}, },
restrictions: %{ restrictions: %{

View file

@ -254,6 +254,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
with {:is_owned, %Actor{} = organizer_actor} <- User.owns_actor(user, organizer_actor_id), with {:is_owned, %Actor{} = organizer_actor} <- User.owns_actor(user, organizer_actor_id),
{:can_create_event, true} <- can_create_event(args), {:can_create_event, true} <- can_create_event(args),
{:event_external, true} <- edit_event_external_checker(args),
{:organizer_group_member, true} <- {:organizer_group_member, true} <-
{:organizer_group_member, is_organizer_group_member?(args)}, {:organizer_group_member, is_organizer_group_member?(args)},
args_with_organizer <- args_with_organizer <-
@ -281,6 +282,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
"Only groups can create events" "Only groups can create events"
)} )}
{:event_external, false} ->
{:error,
dgettext(
"errors",
"Providing external registration is not allowed"
)}
{:organizer_group_member, false} -> {:organizer_group_member, false} ->
{:error, {:error,
dgettext( dgettext(
@ -322,6 +330,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
end end
end end
@spec edit_event_external_checker(map()) :: {:event_external, boolean()}
defp edit_event_external_checker(args) do
if Config.instance_event_external_enabled?() do
{:event_external, true}
else
{:event_external,
Map.get(args, :join_options) != :external and
is_nil(Map.get(args, :external_participation_url))}
end
end
@doc """ @doc """
Update an event Update an event
""" """
@ -340,6 +359,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
args <- extract_timezone(args, user.id), args <- extract_timezone(args, user.id),
{:event_can_be_managed, true} <- {:event_can_be_managed, true} <-
{:event_can_be_managed, can_event_be_updated_by?(event, actor)}, {:event_can_be_managed, can_event_be_updated_by?(event, actor)},
{:event_external, true} <- edit_event_external_checker(args),
{:ok, %Activity{data: %{"object" => %{"type" => "Event"}}}, %Event{} = event} <- {:ok, %Activity{data: %{"object" => %{"type" => "Event"}}}, %Event{} = event} <-
API.Events.update_event(args, event) do API.Events.update_event(args, event) do
{:ok, event} {:ok, event}
@ -351,6 +371,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
"This profile doesn't have permission to update an event on behalf of this group" "This profile doesn't have permission to update an event on behalf of this group"
)} )}
{:event_external, false} ->
{:error,
dgettext(
"errors",
"Providing external registration is not allowed"
)}
{:error, :event_not_found} -> {:error, :event_not_found} ->
{:error, dgettext("errors", "Event not found")} {:error, dgettext("errors", "Event not found")}

View file

@ -305,6 +305,10 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
description: "Whether the group can be join freely, with approval or is invite-only." description: "Whether the group can be join freely, with approval or is invite-only."
) )
arg(:manually_approves_followers, :boolean,
description: "Whether this group approves new followers manually"
)
arg(:avatar, :media_input, arg(:avatar, :media_input,
description: description:
"The avatar for the group, either as an object or directly the ID of an existing media" "The avatar for the group, either as an object or directly the ID of an existing media"

View file

@ -314,6 +314,10 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
description: "Whether event creation is allowed on this instance" description: "Whether event creation is allowed on this instance"
) )
field(:event_external, :boolean,
description: "Whether redirecting to external providers is authorized in event edition"
)
field(:antispam, :boolean, description: "Whether anti-spam is activated on this instance") field(:antispam, :boolean, description: "Whether anti-spam is activated on this instance")
end end

View file

@ -35,6 +35,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
field(:status, :event_status, description: "Status of the event") field(:status, :event_status, description: "Status of the event")
field(:visibility, :event_visibility, description: "The event's visibility") field(:visibility, :event_visibility, description: "The event's visibility")
field(:join_options, :event_join_options, description: "The event's visibility") field(:join_options, :event_join_options, description: "The event's visibility")
field(:external_participation_url, :string, description: "External URL for participation")
field(:picture, :media, field(:picture, :media,
description: "The event's picture", description: "The event's picture",
@ -130,6 +131,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
value(:free, description: "Anyone can join and is automatically accepted") value(:free, description: "Anyone can join and is automatically accepted")
value(:restricted, description: "Manual acceptation") value(:restricted, description: "Manual acceptation")
value(:invite, description: "Participants must be invited") value(:invite, description: "Participants must be invited")
value(:external, description: "External registration")
end end
@desc "The list of possible options for the event's status" @desc "The list of possible options for the event's status"
@ -398,6 +400,8 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
description: "The event's options to join" description: "The event's options to join"
) )
arg(:external_participation_url, :string, description: "External URL for participation")
arg(:tags, list_of(:string), arg(:tags, list_of(:string),
default_value: [], default_value: [],
description: "The list of tags associated to the event" description: "The list of tags associated to the event"
@ -469,6 +473,8 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
description: "The event's options to join" description: "The event's options to join"
) )
arg(:external_participation_url, :string, description: "External URL for participation")
arg(:tags, list_of(:string), description: "The list of tags associated to the event") arg(:tags, list_of(:string), description: "The list of tags associated to the event")
arg(:picture, :media_input, arg(:picture, :media_input,

View file

@ -146,7 +146,8 @@ defmodule Mobilizon.Actors.Actor do
:domain, :domain,
:summary, :summary,
:visibility, :visibility,
:openness :openness,
:manually_approves_followers
] ]
@group_creation_attrs @group_creation_required_attrs ++ @group_creation_optional_attrs @group_creation_attrs @group_creation_required_attrs ++ @group_creation_optional_attrs

View file

@ -356,6 +356,10 @@ defmodule Mobilizon.Config do
def instance_event_creation_enabled?, def instance_event_creation_enabled?,
do: :mobilizon |> Application.get_env(:events) |> Keyword.get(:creation) do: :mobilizon |> Application.get_env(:events) |> Keyword.get(:creation)
@spec instance_event_external_enabled? :: boolean
def instance_event_external_enabled?,
do: :mobilizon |> Application.get_env(:events) |> Keyword.get(:external)
@spec instance_export_formats :: %{event_participants: list(String.t())} @spec instance_export_formats :: %{event_participants: list(String.t())}
def instance_export_formats do def instance_export_formats do
%{ %{

View file

@ -47,6 +47,7 @@ defmodule Mobilizon.Events.Event do
draft: boolean, draft: boolean,
visibility: atom(), visibility: atom(),
join_options: atom(), join_options: atom(),
external_participation_url: String.t(),
publish_at: DateTime.t() | nil, publish_at: DateTime.t() | nil,
uuid: Ecto.UUID.t(), uuid: Ecto.UUID.t(),
online_address: String.t() | nil, online_address: String.t() | nil,
@ -81,6 +82,7 @@ defmodule Mobilizon.Events.Event do
:local, :local,
:visibility, :visibility,
:join_options, :join_options,
:external_participation_url,
:publish_at, :publish_at,
:online_address, :online_address,
:phone_address, :phone_address,
@ -105,6 +107,7 @@ defmodule Mobilizon.Events.Event do
field(:draft, :boolean, default: false) field(:draft, :boolean, default: false)
field(:visibility, EventVisibility, default: :public) field(:visibility, EventVisibility, default: :public)
field(:join_options, JoinOptions, default: :free) field(:join_options, JoinOptions, default: :free)
field(:external_participation_url, :string)
field(:publish_at, :utc_datetime) field(:publish_at, :utc_datetime)
field(:uuid, Ecto.UUID, default: Ecto.UUID.generate()) field(:uuid, Ecto.UUID, default: Ecto.UUID.generate())
field(:online_address, :string) field(:online_address, :string)

View file

@ -46,7 +46,8 @@ defmodule Mobilizon.Events do
defenum(JoinOptions, :join_options, [ defenum(JoinOptions, :join_options, [
:free, :free,
:restricted, :restricted,
:invite :invite,
:external
]) ])
defenum(EventStatus, :event_status, [ defenum(EventStatus, :event_status, [

View file

@ -1,7 +1,7 @@
defmodule Mobilizon.Mixfile do defmodule Mobilizon.Mixfile do
use Mix.Project use Mix.Project
@version "3.2.0-beta.1" @version "3.2.0-beta.2"
def project do def project do
[ [

View file

@ -0,0 +1,33 @@
defmodule Mobilizon.Storage.Repo.Migrations.AddExternalUrlForEvents do
use Ecto.Migration
alias Mobilizon.Events.JoinOptions
def up do
alter table(:events) do
add(:external_participation_url, :string)
end
reset_join_options_enum()
end
def down do
alter table(:events) do
remove(:external_participation_url, :string)
end
reset_join_options_enum()
end
defp reset_join_options_enum do
execute("ALTER TABLE events ALTER COLUMN join_options TYPE VARCHAR USING join_options::text")
execute("ALTER TABLE events ALTER COLUMN join_options DROP DEFAULT")
JoinOptions.drop_type()
JoinOptions.create_type()
execute(
"ALTER TABLE events ALTER COLUMN join_options TYPE join_options USING join_options::join_options"
)
execute("ALTER TABLE events ALTER COLUMN join_options SET DEFAULT 'free'::join_options")
end
end

View file

@ -1551,6 +1551,9 @@ type RootMutationType {
"The event's options to join" "The event's options to join"
joinOptions: EventJoinOptions joinOptions: EventJoinOptions
"External URL for participation"
externalParticipationUrl: String
"The list of tags associated to the event" "The list of tags associated to the event"
tags: [String] tags: [String]
@ -1620,6 +1623,9 @@ type RootMutationType {
"The event's options to join" "The event's options to join"
joinOptions: EventJoinOptions joinOptions: EventJoinOptions
"External URL for participation"
externalParticipationUrl: String
"The list of tags associated to the event" "The list of tags associated to the event"
tags: [String] tags: [String]
@ -2956,6 +2962,9 @@ type Event implements ActivityObject & Interactable & ActionLogObject {
"The event's visibility" "The event's visibility"
joinOptions: EventJoinOptions joinOptions: EventJoinOptions
"External URL for participation"
externalParticipationUrl: String
"The event's picture" "The event's picture"
picture: Media picture: Media
@ -3144,6 +3153,9 @@ enum EventJoinOptions {
"Participants must be invited" "Participants must be invited"
INVITE INVITE
"External registration"
EXTERNAL
} }
type InstanceFeeds { type InstanceFeeds {