Merge branch 'external-events' into 'main'

External events

See merge request framasoft/mobilizon!1229
This commit is contained in:
Thomas Citharel 2023-09-01 16:42:29 +00:00
commit 19f595c2d8
21 changed files with 209 additions and 10 deletions

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,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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -357,6 +357,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

@ -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 {