[WIP] Added domain management

This commit is contained in:
Sven Heidemann 2025-01-11 00:41:29 +01:00
parent 865e6465cf
commit 1001b6db5f
45 changed files with 1130 additions and 23 deletions

View File

@ -0,0 +1,13 @@
from api_graphql.abc.db_model_filter_abc import DbModelFilterABC
from api_graphql.abc.filter.string_filter import StringFilter
class DomainFilter(DbModelFilterABC):
def __init__(
self,
obj: dict,
):
DbModelFilterABC.__init__(self, obj)
self.add_field("name", StringFilter)
self.add_field("description", StringFilter)

View File

@ -0,0 +1,53 @@
type DomainResult {
totalCount: Int
count: Int
nodes: [Domain]
}
type Domain implements DbModel {
id: ID
name: String
shortUrls: [ShortUrl]
deleted: Boolean
editor: User
createdUtc: String
updatedUtc: String
}
input DomainSort {
id: SortOrder
name: SortOrder
deleted: SortOrder
editorId: SortOrder
createdUtc: SortOrder
updatedUtc: SortOrder
}
input DomainFilter {
id: IntFilter
name: StringFilter
deleted: BooleanFilter
editor: IntFilter
createdUtc: DateFilter
updatedUtc: DateFilter
}
type DomainMutation {
create(input: DomainCreateInput!): Domain
update(input: DomainUpdateInput!): Domain
delete(id: ID!): Boolean
restore(id: ID!): Boolean
}
input DomainCreateInput {
name: String!
}
input DomainUpdateInput {
id: ID!
name: String
}

View File

@ -5,5 +5,6 @@ type Mutation {
role: RoleMutation role: RoleMutation
group: GroupMutation group: GroupMutation
domain: DomainMutation
shortUrl: ShortUrlMutation shortUrl: ShortUrlMutation
} }

View File

@ -11,6 +11,7 @@ type Query {
userHasAnyPermission(permissions: [String]!): Boolean userHasAnyPermission(permissions: [String]!): Boolean
notExistingUsersFromKeycloak: KeycloakUserResult notExistingUsersFromKeycloak: KeycloakUserResult
domains(filter: [DomainFilter], sort: [DomainSort], skip: Int, take: Int): DomainResult
groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult
shortUrls(filter: [ShortUrlFilter], sort: [ShortUrlSort], skip: Int, take: Int): ShortUrlResult shortUrls(filter: [ShortUrlFilter], sort: [ShortUrlSort], skip: Int, take: Int): ShortUrlResult
} }

View File

@ -11,6 +11,7 @@ type ShortUrl implements DbModel {
description: String description: String
visits: Int visits: Int
group: Group group: Group
domain: Domain
loadingScreen: Boolean loadingScreen: Boolean
deleted: Boolean deleted: Boolean
@ -55,6 +56,7 @@ input ShortUrlCreateInput {
targetUrl: String! targetUrl: String!
description: String description: String
groupId: ID groupId: ID
domainId: ID
loadingScreen: Boolean loadingScreen: Boolean
} }
@ -64,5 +66,6 @@ input ShortUrlUpdateInput {
targetUrl: String targetUrl: String
description: String description: String
groupId: ID groupId: ID
domainId: ID
loadingScreen: Boolean loadingScreen: Boolean
} }

View File

@ -0,0 +1,13 @@
from api_graphql.abc.input_abc import InputABC
class DomainCreateInput(InputABC):
def __init__(self, src: dict):
InputABC.__init__(self, src)
self._name = self.option("name", str, required=True)
@property
def name(self) -> str:
return self._name

View File

@ -0,0 +1,18 @@
from api_graphql.abc.input_abc import InputABC
class DomainUpdateInput(InputABC):
def __init__(self, src: dict):
InputABC.__init__(self, src)
self._id = self.option("id", int, required=True)
self._name = self.option("name", str)
@property
def id(self) -> int:
return self._id
@property
def name(self) -> str:
return self._name

View File

@ -12,6 +12,7 @@ class ShortUrlCreateInput(InputABC):
self._target_url = self.option("targetUrl", str, required=True) self._target_url = self.option("targetUrl", str, required=True)
self._description = self.option("description", str) self._description = self.option("description", str)
self._group_id = self.option("groupId", int) self._group_id = self.option("groupId", int)
self._domain_id = self.option("domainId", int)
self._loading_screen = self.option("loadingScreen", bool) self._loading_screen = self.option("loadingScreen", bool)
@property @property
@ -30,6 +31,10 @@ class ShortUrlCreateInput(InputABC):
def group_id(self) -> Optional[int]: def group_id(self) -> Optional[int]:
return self._group_id return self._group_id
@property
def domain_id(self) -> Optional[int]:
return self._domain_id
@property @property
def loading_screen(self) -> Optional[str]: def loading_screen(self) -> Optional[str]:
return self._loading_screen return self._loading_screen

View File

@ -13,6 +13,7 @@ class ShortUrlUpdateInput(InputABC):
self._target_url = self.option("targetUrl", str) self._target_url = self.option("targetUrl", str)
self._description = self.option("description", str) self._description = self.option("description", str)
self._group_id = self.option("groupId", int) self._group_id = self.option("groupId", int)
self._domain_id = self.option("domainId", int)
self._loading_screen = self.option("loadingScreen", bool) self._loading_screen = self.option("loadingScreen", bool)
@property @property
@ -35,6 +36,10 @@ class ShortUrlUpdateInput(InputABC):
def group_id(self) -> Optional[int]: def group_id(self) -> Optional[int]:
return self._group_id return self._group_id
@property
def domain_id(self) -> Optional[int]:
return self._domain_id
@property @property
def loading_screen(self) -> Optional[str]: def loading_screen(self) -> Optional[str]:
return self._loading_screen return self._loading_screen

View File

@ -33,6 +33,16 @@ class Mutation(MutationABC):
], ],
) )
self.add_mutation_type(
"domain",
"Domain",
require_any_permission=[
Permissions.domains_create,
Permissions.domains_update,
Permissions.domains_delete,
],
)
self.add_mutation_type( self.add_mutation_type(
"group", "group",
"Group", "Group",

View File

@ -0,0 +1,75 @@
from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.input.domain_create_input import DomainCreateInput
from api_graphql.input.domain_update_input import DomainUpdateInput
from api_graphql.input.group_create_input import GroupCreateInput
from api_graphql.input.group_update_input import GroupUpdateInput
from core.logger import APILogger
from data.schemas.public.domain_dao import domainDao
from data.schemas.public.group import Group
from service.permission.permissions_enum import Permissions
logger = APILogger(__name__)
class DomainMutation(MutationABC):
def __init__(self):
MutationABC.__init__(self, "Domain")
self.mutation(
"create",
self.resolve_create,
DomainCreateInput,
require_any_permission=[Permissions.domains_create],
)
self.mutation(
"update",
self.resolve_update,
DomainUpdateInput,
require_any_permission=[Permissions.domains_update],
)
self.mutation(
"delete",
self.resolve_delete,
require_any_permission=[Permissions.domains_delete],
)
self.mutation(
"restore",
self.resolve_restore,
require_any_permission=[Permissions.domains_delete],
)
@staticmethod
async def resolve_create(obj: GroupCreateInput, *_):
logger.debug(f"create domain: {obj.__dict__}")
domain = Group(
0,
obj.name,
)
nid = await domainDao.create(domain)
return await domainDao.get_by_id(nid)
@staticmethod
async def resolve_update(obj: GroupUpdateInput, *_):
logger.debug(f"update domain: {input}")
if obj.name is not None:
domain = await domainDao.get_by_id(obj.id)
domain.name = obj.name
await domainDao.update(domain)
return await domainDao.get_by_id(obj.id)
@staticmethod
async def resolve_delete(*_, id: str):
logger.debug(f"delete domain: {id}")
domain = await domainDao.get_by_id(id)
await domainDao.delete(domain)
return True
@staticmethod
async def resolve_restore(*_, id: str):
logger.debug(f"restore domain: {id}")
domain = await domainDao.get_by_id(id)
await domainDao.restore(domain)
return True

View File

@ -4,6 +4,7 @@ from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.input.short_url_create_input import ShortUrlCreateInput from api_graphql.input.short_url_create_input import ShortUrlCreateInput
from api_graphql.input.short_url_update_input import ShortUrlUpdateInput from api_graphql.input.short_url_update_input import ShortUrlUpdateInput
from core.logger import APILogger from core.logger import APILogger
from data.schemas.public.domain_dao import domainDao
from data.schemas.public.group_dao import groupDao from data.schemas.public.group_dao import groupDao
from data.schemas.public.short_url import ShortUrl from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao from data.schemas.public.short_url_dao import shortUrlDao
@ -49,6 +50,7 @@ class ShortUrlMutation(MutationABC):
obj.target_url, obj.target_url,
obj.description, obj.description,
obj.group_id, obj.group_id,
obj.domain_id,
obj.loading_screen, obj.loading_screen,
) )
nid = await shortUrlDao.create(short_url) nid = await shortUrlDao.create(short_url)
@ -74,6 +76,16 @@ class ShortUrlMutation(MutationABC):
if group_by_id is None: if group_by_id is None:
raise NotFound(f"Group with id {obj.group_id} does not exist") raise NotFound(f"Group with id {obj.group_id} does not exist")
short_url.group_id = obj.group_id short_url.group_id = obj.group_id
else:
short_url.group_id = None
if obj.domain_id is not None:
domain_by_id = await domainDao.find_by_id(obj.domain_id)
if domain_by_id is None:
raise NotFound(f"Domain with id {obj.domain_id} does not exist")
short_url.domain_id = obj.domain_id
else:
short_url.domain_id = None
if obj.loading_screen is not None: if obj.loading_screen is not None:
short_url.loading_screen = obj.loading_screen short_url.loading_screen = obj.loading_screen

View File

@ -0,0 +1,17 @@
from api_graphql.abc.db_model_query_abc import DbModelQueryABC
from data.schemas.public.domain import Domain
from data.schemas.public.group import Group
from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao
class DomainQuery(DbModelQueryABC):
def __init__(self):
DbModelQueryABC.__init__(self, "Domain")
self.set_field("name", lambda x, *_: x.name)
self.set_field("shortUrls", self._get_urls)
@staticmethod
async def _get_urls(domain: Domain, *_):
return await shortUrlDao.find_by({ShortUrl.domain_id: domain.id})

View File

@ -9,5 +9,6 @@ class ShortUrlQuery(DbModelQueryABC):
self.set_field("targetUrl", lambda x, *_: x.target_url) self.set_field("targetUrl", lambda x, *_: x.target_url)
self.set_field("description", lambda x, *_: x.description) self.set_field("description", lambda x, *_: x.description)
self.set_field("group", lambda x, *_: x.group) self.set_field("group", lambda x, *_: x.group)
self.set_field("domain", lambda x, *_: x.domain)
self.set_field("visits", lambda x, *_: x.visit_count) self.set_field("visits", lambda x, *_: x.visit_count)
self.set_field("loadingScreen", lambda x, *_: x.loading_screen) self.set_field("loadingScreen", lambda x, *_: x.loading_screen)

View File

@ -5,6 +5,7 @@ from api_graphql.abc.sort_abc import Sort
from api_graphql.field.dao_field_builder import DaoFieldBuilder from api_graphql.field.dao_field_builder import DaoFieldBuilder
from api_graphql.field.resolver_field_builder import ResolverFieldBuilder from api_graphql.field.resolver_field_builder import ResolverFieldBuilder
from api_graphql.filter.api_key_filter import ApiKeyFilter from api_graphql.filter.api_key_filter import ApiKeyFilter
from api_graphql.filter.domain_filter import DomainFilter
from api_graphql.filter.group_filter import GroupFilter from api_graphql.filter.group_filter import GroupFilter
from api_graphql.filter.permission_filter import PermissionFilter from api_graphql.filter.permission_filter import PermissionFilter
from api_graphql.filter.role_filter import RoleFilter from api_graphql.filter.role_filter import RoleFilter
@ -18,6 +19,8 @@ from data.schemas.permission.permission import Permission
from data.schemas.permission.permission_dao import permissionDao from data.schemas.permission.permission_dao import permissionDao
from data.schemas.permission.role import Role from data.schemas.permission.role import Role
from data.schemas.permission.role_dao import roleDao from data.schemas.permission.role_dao import roleDao
from data.schemas.public.domain import Domain
from data.schemas.public.domain_dao import domainDao
from data.schemas.public.group import Group from data.schemas.public.group import Group
from data.schemas.public.group_dao import groupDao from data.schemas.public.group_dao import groupDao
from data.schemas.public.short_url import ShortUrl from data.schemas.public.short_url import ShortUrl
@ -80,6 +83,20 @@ class Query(QueryABC):
.with_require_any_permission([Permissions.users_create]) .with_require_any_permission([Permissions.users_create])
) )
self.field(
DaoFieldBuilder("domains")
.with_dao(domainDao)
.with_filter(DomainFilter)
.with_sort(Sort[Domain])
.with_require_any_permission(
[
Permissions.domains,
Permissions.short_urls_create,
Permissions.short_urls_update,
]
)
)
self.field( self.field(
DaoFieldBuilder("groups") DaoFieldBuilder("groups")
.with_dao(groupDao) .with_dao(groupDao)

View File

@ -0,0 +1,27 @@
from datetime import datetime
from typing import Optional
from core.database.abc.db_model_abc import DbModelABC
from core.typing import SerialId
class Domain(DbModelABC):
def __init__(
self,
id: SerialId,
name: str,
deleted: bool = False,
editor_id: Optional[SerialId] = None,
created: Optional[datetime] = None,
updated: Optional[datetime] = None,
):
DbModelABC.__init__(self, id, deleted, editor_id, created, updated)
self._name = name
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str):
self._name = value

View File

@ -0,0 +1,22 @@
from core.logger import DBLogger
from data.schemas.public.domain import Domain
from data.schemas.public.group import Group
logger = DBLogger(__name__)
from core.database.abc.db_model_dao_abc import DbModelDaoABC
class DomainDao(DbModelDaoABC[Group]):
def __init__(self):
DbModelDaoABC.__init__(self, __name__, Group, "public.domains")
self.attribute(Domain.name, str)
async def get_by_name(self, name: str) -> Group:
result = await self._db.select_map(
f"SELECT * FROM {self._table_name} WHERE Name = '{name}'"
)
return self.to_object(result[0])
domainDao = DomainDao()

View File

@ -16,6 +16,7 @@ class ShortUrl(DbModelABC):
target_url: str, target_url: str,
description: Optional[str], description: Optional[str],
group_id: Optional[SerialId], group_id: Optional[SerialId],
domain_id: Optional[SerialId],
loading_screen: Optional[str] = None, loading_screen: Optional[str] = None,
deleted: bool = False, deleted: bool = False,
editor_id: Optional[SerialId] = None, editor_id: Optional[SerialId] = None,
@ -27,6 +28,7 @@ class ShortUrl(DbModelABC):
self._target_url = target_url self._target_url = target_url
self._description = description self._description = description
self._group_id = group_id self._group_id = group_id
self._domain_id = domain_id
self._loading_screen = loading_screen self._loading_screen = loading_screen
@property @property
@ -70,6 +72,23 @@ class ShortUrl(DbModelABC):
return await groupDao.get_by_id(self._group_id) return await groupDao.get_by_id(self._group_id)
@property
def domain_id(self) -> SerialId:
return self._domain_id
@domain_id.setter
def domain_id(self, value: SerialId):
self._domain_id = value
@async_property
async def domain(self) -> Optional[Group]:
if self._domain_id is None:
return None
from data.schemas.public.domain_dao import domainDao
return await domainDao.get_by_id(self._domain_id)
@async_property @async_property
async def visit_count(self) -> int: async def visit_count(self) -> int:
from data.schemas.public.short_url_visit_dao import shortUrlVisitDao from data.schemas.public.short_url_visit_dao import shortUrlVisitDao

View File

@ -13,6 +13,7 @@ class ShortUrlDao(DbModelDaoABC[ShortUrl]):
self.attribute(ShortUrl.target_url, str) self.attribute(ShortUrl.target_url, str)
self.attribute(ShortUrl.description, str) self.attribute(ShortUrl.description, str)
self.attribute(ShortUrl.group_id, int) self.attribute(ShortUrl.group_id, int)
self.attribute(ShortUrl.domain_id, int)
self.attribute(ShortUrl.loading_screen, bool) self.attribute(ShortUrl.loading_screen, bool)

View File

@ -0,0 +1,32 @@
CREATE
SCHEMA IF NOT EXISTS public;
-- groups
CREATE TABLE IF NOT EXISTS public.domains
(
Id SERIAL PRIMARY KEY,
Name VARCHAR(255) NOT NULL,
-- for history
Deleted BOOLEAN NOT NULL DEFAULT FALSE,
EditorId INT NULL REFERENCES administration.users (Id),
CreatedUtc timestamptz NOT NULL DEFAULT NOW(),
UpdatedUtc timestamptz NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS public.domains_history
(
LIKE public.domains
);
CREATE TRIGGER domains_history_trigger
BEFORE INSERT OR UPDATE OR DELETE
ON public.domains
FOR EACH ROW
EXECUTE FUNCTION public.history_trigger_function();
ALTER TABLE public.short_urls
ADD COLUMN domainId INT NULL REFERENCES public.domains (Id);
ALTER TABLE public.short_urls_history
ADD COLUMN domainId INT NULL REFERENCES public.domains (Id);

View File

@ -31,6 +31,12 @@ class Permissions(Enum):
""" """
Public Public
""" """
# domains
domains = "domains"
domains_create = "domains.create"
domains_update = "domains.update"
domains_delete = "domains.delete"
# groups # groups
groups = "groups" groups = "groups"
groups_create = "groups.create" groups_create = "groups.create"

View File

@ -1,30 +1,35 @@
export enum PermissionsEnum { export enum PermissionsEnum {
// Administration // Administration
apiKeys = "api_keys", apiKeys = 'api_keys',
apiKeysCreate = "api_keys.create", apiKeysCreate = 'api_keys.create',
apiKeysUpdate = "api_keys.update", apiKeysUpdate = 'api_keys.update',
apiKeysDelete = "api_keys.delete", apiKeysDelete = 'api_keys.delete',
// Users // Users
users = "users", users = 'users',
usersCreate = "users.create", usersCreate = 'users.create',
usersUpdate = "users.update", usersUpdate = 'users.update',
usersDelete = "users.delete", usersDelete = 'users.delete',
// Permissions // Permissions
roles = "roles", roles = 'roles',
rolesCreate = "roles.create", rolesCreate = 'roles.create',
rolesUpdate = "roles.update", rolesUpdate = 'roles.update',
rolesDelete = "roles.delete", rolesDelete = 'roles.delete',
// Public // Public
groups = "groups", domains = 'domains',
groupsCreate = "groups.create", domainsCreate = 'domains.create',
groupsUpdate = "groups.update", domainsUpdate = 'domains.update',
groupsDelete = "groups.delete", domainsDelete = 'domains.delete',
shortUrls = "short_urls", groups = 'groups',
shortUrlsCreate = "short_urls.create", groupsCreate = 'groups.create',
shortUrlsUpdate = "short_urls.update", groupsUpdate = 'groups.update',
shortUrlsDelete = "short_urls.delete", groupsDelete = 'groups.delete',
shortUrls = 'short_urls',
shortUrlsCreate = 'short_urls.create',
shortUrlsUpdate = 'short_urls.update',
shortUrlsDelete = 'short_urls.delete',
} }

View File

@ -0,0 +1,14 @@
import { DbModel } from 'src/app/model/entities/db-model';
export interface Domain extends DbModel {
name: string;
}
export interface DomainCreateInput {
name: string;
}
export interface DomainUpdateInput {
id: number;
name: string;
}

View File

@ -1,5 +1,6 @@
import { DbModel } from 'src/app/model/entities/db-model'; import { DbModel } from 'src/app/model/entities/db-model';
import { Group } from 'src/app/model/entities/group'; import { Group } from 'src/app/model/entities/group';
import { Domain } from 'src/app/model/entities/domain';
export interface ShortUrl extends DbModel { export interface ShortUrl extends DbModel {
shortUrl: string; shortUrl: string;
@ -8,6 +9,7 @@ export interface ShortUrl extends DbModel {
loadingScreen: boolean; loadingScreen: boolean;
visits: number; visits: number;
group?: Group; group?: Group;
domain?: Domain;
} }
export interface ShortUrlDto { export interface ShortUrlDto {
@ -22,6 +24,7 @@ export interface ShortUrlCreateInput {
description: string; description: string;
loadingScreen: boolean; loadingScreen: boolean;
groupId: number; groupId: number;
domainId: number;
} }
export interface ShortUrlUpdateInput { export interface ShortUrlUpdateInput {
@ -31,4 +34,5 @@ export interface ShortUrlUpdateInput {
description: string; description: string;
loadingScreen: boolean; loadingScreen: boolean;
groupId: number; groupId: number;
domainId: number;
} }

View File

@ -6,6 +6,15 @@ import { PermissionGuard } from 'src/app/core/guard/permission.guard';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
const routes: Routes = [ const routes: Routes = [
{
path: 'domains',
loadChildren: () =>
import('src/app/modules/admin/domains/domains.module').then(
m => m.DomainsModule
),
canActivate: [PermissionGuard],
data: { permissions: [PermissionsEnum.domains] },
},
{ {
path: 'groups', path: 'groups',
loadChildren: () => loadChildren: () =>

View File

@ -0,0 +1,35 @@
import { Injectable, Provider } from '@angular/core';
import {
DB_MODEL_COLUMNS,
ID_COLUMN,
PageColumns,
} from 'src/app/core/base/page.columns';
import { TableColumn } from 'src/app/modules/shared/components/table/table.model';
import { Domain } from 'src/app/model/entities/domain';
@Injectable()
export class DomainsColumns extends PageColumns<Domain> {
get(): TableColumn<Domain>[] {
return [
ID_COLUMN,
{
name: 'name',
label: 'common.name',
type: 'text',
filterable: true,
value: (row: Domain) => row.name,
},
...DB_MODEL_COLUMNS,
];
}
static provide(): Provider[] {
return [
{
provide: PageColumns,
useClass: DomainsColumns,
},
DomainsColumns,
];
}
}

View File

@ -0,0 +1,232 @@
import { Injectable, Provider } from '@angular/core';
import { Observable } from 'rxjs';
import {
Create,
Delete,
PageDataService,
Restore,
Update,
} from 'src/app/core/base/page.data.service';
import { Filter } from 'src/app/model/graphql/filter/filter.model';
import { Sort } from 'src/app/model/graphql/filter/sort.model';
import { Apollo, gql } from 'apollo-angular';
import { QueryResult } from 'src/app/model/entities/query-result';
import { DB_MODEL_FRAGMENT } from 'src/app/model/graphql/db-model.query';
import { catchError, map } from 'rxjs/operators';
import { SpinnerService } from 'src/app/service/spinner.service';
import {
Domain,
DomainCreateInput,
DomainUpdateInput,
} from 'src/app/model/entities/domain';
@Injectable()
export class DomainsDataService
extends PageDataService<Domain>
implements
Create<Domain, DomainCreateInput>,
Update<Domain, DomainUpdateInput>,
Delete<Domain>,
Restore<Domain>
{
constructor(
private spinner: SpinnerService,
private apollo: Apollo
) {
super();
}
load(
filter?: Filter[] | undefined,
sort?: Sort[] | undefined,
skip?: number | undefined,
take?: number | undefined
): Observable<QueryResult<Domain>> {
return this.apollo
.query<{ domains: QueryResult<Domain> }>({
query: gql`
query getDomains(
$filter: [DomainFilter]
$sort: [DomainSort]
$skip: Int
$take: Int
) {
domains(filter: $filter, sort: $sort, skip: $skip, take: $take) {
count
totalCount
nodes {
id
name
...DB_MODEL
}
}
}
${DB_MODEL_FRAGMENT}
`,
variables: {
filter: filter,
sort: sort,
skip: skip,
take: take,
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data.domains));
}
loadById(id: number): Observable<Domain> {
return this.apollo
.query<{ domains: QueryResult<Domain> }>({
query: gql`
query getDomain($id: Int) {
domain(filter: { id: { equal: $id } }) {
id
name
...DB_MODEL
}
}
${DB_MODEL_FRAGMENT}
`,
variables: {
id: id,
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data.domains.nodes[0]));
}
create(object: DomainCreateInput): Observable<Domain | undefined> {
return this.apollo
.mutate<{ domain: { create: Domain } }>({
mutation: gql`
mutation createDomain($input: DomainCreateInput!) {
domain {
create(input: $input) {
id
name
...DB_MODEL
}
}
}
${DB_MODEL_FRAGMENT}
`,
variables: {
input: {
name: object.name,
},
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data?.domain.create));
}
update(object: DomainUpdateInput): Observable<Domain | undefined> {
return this.apollo
.mutate<{ domain: { update: Domain } }>({
mutation: gql`
mutation updateDomain($input: DomainUpdateInput!) {
domain {
update(input: $input) {
id
name
...DB_MODEL
}
}
}
${DB_MODEL_FRAGMENT}
`,
variables: {
input: {
id: object.id,
name: object.name,
},
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data?.domain.update));
}
delete(object: Domain): Observable<boolean> {
return this.apollo
.mutate<{ domain: { delete: boolean } }>({
mutation: gql`
mutation deleteDomain($id: ID!) {
domain {
delete(id: $id)
}
}
`,
variables: {
id: object.id,
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data?.domain.delete ?? false));
}
restore(object: Domain): Observable<boolean> {
return this.apollo
.mutate<{ domain: { restore: boolean } }>({
mutation: gql`
mutation restoreDomain($id: ID!) {
domain {
restore(id: $id)
}
}
`,
variables: {
id: object.id,
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data?.domain.restore ?? false));
}
static provide(): Provider[] {
return [
{
provide: PageDataService,
useClass: DomainsDataService,
},
DomainsDataService,
];
}
}

View File

@ -0,0 +1,43 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from 'src/app/modules/shared/shared.module';
import { RouterModule, Routes } from '@angular/router';
import { PermissionGuard } from 'src/app/core/guard/permission.guard';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { DomainsPage } from 'src/app/modules/admin/domains/domains.page';
import { DomainFormPageComponent } from 'src/app/modules/admin/domains/form-page/domain-form-page.component';
import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service';
import { DomainsColumns } from 'src/app/modules/admin/domains/domains.columns';
const routes: Routes = [
{
path: '',
title: 'Domains',
component: DomainsPage,
children: [
{
path: 'create',
component: DomainFormPageComponent,
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.apiKeysCreate],
},
},
{
path: 'edit/:id',
component: DomainFormPageComponent,
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.apiKeysUpdate],
},
},
],
},
];
@NgModule({
declarations: [DomainsPage, DomainFormPageComponent],
imports: [CommonModule, SharedModule, RouterModule.forChild(routes)],
providers: [DomainsDataService.provide(), DomainsColumns.provide()],
})
export class DomainsModule {}

View File

@ -0,0 +1,19 @@
<app-table
[rows]="result.nodes"
[columns]="columns"
[rowsPerPageOptions]="rowsPerPageOptions"
[totalCount]="result.totalCount"
[requireAnyPermissions]="requiredPermissions"
countHeaderTranslation="domain.count_header"
[loading]="loading"
[(filter)]="filter"
[(sort)]="sort"
[(skip)]="skip"
[(take)]="take"
(load)="load()"
[create]="true"
[update]="true"
(delete)="delete($event)"
(restore)="restore($event)"></app-table>
<router-outlet></router-outlet>

View File

@ -0,0 +1,51 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ApiKeysPage } from "src/app/modules/admin/administration/api-keys/api-keys.page";
import { SharedModule } from "src/app/modules/shared/shared.module";
import { TranslateModule } from "@ngx-translate/core";
import { AuthService } from "src/app/service/auth.service";
import { KeycloakService } from "keycloak-angular";
import { ErrorHandlingService } from "src/app/service/error-handling.service";
import { ToastService } from "src/app/service/toast.service";
import { ConfirmationService, MessageService } from "primeng/api";
import { ActivatedRoute } from "@angular/router";
import { of } from "rxjs";
import { PageDataService } from "src/app/core/base/page.data.service";
import { ApiKeysDataService } from "src/app/modules/admin/administration/api-keys/api-keys.data.service";
describe("ApiKeysComponent", () => {
let component: ApiKeysPage;
let fixture: ComponentFixture<ApiKeysPage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ApiKeysPage],
imports: [SharedModule, TranslateModule.forRoot()],
providers: [
AuthService,
KeycloakService,
ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: of({}) },
},
},
{
provide: PageDataService,
useClass: ApiKeysDataService,
},
],
}).compileComponents();
fixture = TestBed.createComponent(ApiKeysPage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,72 @@
import { Component } from '@angular/core';
import { PageBase } from 'src/app/core/base/page-base';
import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { Group } from 'src/app/model/entities/group';
import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service';
import { DomainsColumns } from 'src/app/modules/admin/domains/domains.columns';
@Component({
selector: 'app-domains',
templateUrl: './domains.page.html',
styleUrl: './domains.page.scss',
})
export class DomainsPage extends PageBase<
Group,
DomainsDataService,
DomainsColumns
> {
constructor(
private toast: ToastService,
private confirmation: ConfirmationDialogService
) {
super(true, {
read: [PermissionsEnum.domains],
create: [PermissionsEnum.domainsCreate],
update: [PermissionsEnum.domainsUpdate],
delete: [PermissionsEnum.domainsDelete],
restore: [PermissionsEnum.domainsDelete],
});
}
load(): void {
this.loading = true;
this.dataService
.load(this.filter, this.sort, this.skip, this.take)
.subscribe(result => {
this.result = result;
this.loading = false;
});
}
delete(group: Group): void {
this.confirmation.confirmDialog({
header: 'dialog.delete.header',
message: 'dialog.delete.message',
accept: () => {
this.loading = true;
this.dataService.delete(group).subscribe(() => {
this.toast.success('action.deleted');
this.load();
});
},
messageParams: { entity: group.name },
});
}
restore(group: Group): void {
this.confirmation.confirmDialog({
header: 'dialog.restore.header',
message: 'dialog.restore.message',
accept: () => {
this.loading = true;
this.dataService.restore(group).subscribe(() => {
this.toast.success('action.restored');
this.load();
});
},
messageParams: { entity: group.name },
});
}
}

View File

@ -0,0 +1,31 @@
<app-form-page
*ngIf="node"
[formGroup]="form"
[isUpdate]="isUpdate"
(onSave)="save()"
(onClose)="close()">
<ng-template formPageHeader let-isUpdate>
<h2>
{{ 'common.group' | translate }}
{{
(isUpdate ? 'sidebar.header.update' : 'sidebar.header.create')
| translate
}}
</h2>
</ng-template>
<ng-template formPageContent>
<div class="form-page-input">
<p class="label">{{ 'common.id' | translate }}</p>
<input pInputText class="value" type="number" formControlName="id"/>
</div>
<div class="form-page-input">
<p class="label">{{ 'common.name' | translate }}</p>
<input
pInputText
class="value"
type="text"
formControlName="name"/>
</div>
</ng-template>
</app-form-page>

View File

@ -0,0 +1,50 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { RoleFormPageComponent } from "src/app/modules/admin/administration/roles/form-page/role-form-page.component";
import { SharedModule } from "src/app/modules/shared/shared.module";
import { TranslateModule } from "@ngx-translate/core";
import { AuthService } from "src/app/service/auth.service";
import { ErrorHandlingService } from "src/app/service/error-handling.service";
import { ToastService } from "src/app/service/toast.service";
import { ConfirmationService, MessageService } from "primeng/api";
import { ActivatedRoute } from "@angular/router";
import { of } from "rxjs";
import { ApiKeysDataService } from "src/app/modules/admin/administration/api-keys/api-keys.data.service";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
describe("ApiKeyFormpageComponent", () => {
let component: RoleFormPageComponent;
let fixture: ComponentFixture<RoleFormPageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [RoleFormPageComponent],
imports: [
BrowserAnimationsModule,
SharedModule,
TranslateModule.forRoot(),
],
providers: [
AuthService,
ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: of({}) },
},
},
ApiKeysDataService,
],
}).compileComponents();
fixture = TestBed.createComponent(RoleFormPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,93 @@
import { Component } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastService } from 'src/app/service/toast.service';
import { FormPageBase } from 'src/app/core/base/form-page-base';
import {
Domain,
DomainCreateInput,
DomainUpdateInput,
} from 'src/app/model/entities/domain';
import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service';
@Component({
selector: 'app-domain-form-page',
templateUrl: './domain-form-page.component.html',
styleUrl: './domain-form-page.component.scss',
})
export class DomainFormPageComponent extends FormPageBase<
Domain,
DomainCreateInput,
DomainUpdateInput,
DomainsDataService
> {
constructor(private toast: ToastService) {
super();
if (!this.nodeId) {
this.node = this.new();
this.setForm(this.node);
return;
}
this.dataService
.load([{ id: { equal: this.nodeId } }])
.subscribe(apiKey => {
this.node = apiKey.nodes[0];
this.setForm(this.node);
});
}
new(): Domain {
return {} as Domain;
}
buildForm() {
this.form = new FormGroup({
id: new FormControl<number | undefined>(undefined),
name: new FormControl<string | undefined>(undefined, Validators.required),
});
this.form.controls['id'].disable();
}
setForm(node?: Domain) {
this.form.controls['id'].setValue(node?.id);
this.form.controls['name'].setValue(node?.name);
}
getCreateInput(): DomainCreateInput {
return {
name: this.form.controls['name'].pristine
? undefined
: (this.form.controls['name'].value ?? undefined),
};
}
getUpdateInput(): DomainUpdateInput {
if (!this.node?.id) {
throw new Error('Node id is missing');
}
return {
id: this.form.controls['id'].value,
name: this.form.controls['name'].pristine
? undefined
: (this.form.controls['name'].value ?? undefined),
};
}
create(apiKey: DomainCreateInput): void {
this.dataService.create(apiKey).subscribe(() => {
this.spinner.hide();
this.toast.success('action.created');
this.close();
});
}
update(apiKey: DomainUpdateInput): void {
this.dataService.update(apiKey).subscribe(() => {
this.spinner.hide();
this.toast.success('action.created');
this.close();
});
}
}

View File

@ -6,7 +6,7 @@
(onClose)="close()"> (onClose)="close()">
<ng-template formPageHeader let-isUpdate> <ng-template formPageHeader let-isUpdate>
<h2> <h2>
{{ 'common.group' | translate }} {{ 'common.short_url' | translate }}
{{ {{
(isUpdate ? 'sidebar.header.update' : 'sidebar.header.create') (isUpdate ? 'sidebar.header.update' : 'sidebar.header.create')
| translate | translate
@ -65,5 +65,20 @@
></p-dropdown> ></p-dropdown>
</div> </div>
</div> </div>
<div class="form-page-input">
<p class="label">{{ 'common.domain' | translate }}</p>
<div
class="value">
<p-dropdown
[options]="domains"
formControlName="domainId"
[showClear]="true"
[filter]="true"
filterBy="name"
optionLabel="name"
optionValue="id"
></p-dropdown>
</div>
</div>
</ng-template> </ng-template>
</app-form-page> </app-form-page>

View File

@ -9,6 +9,7 @@ import {
} from 'src/app/model/entities/short-url'; } from 'src/app/model/entities/short-url';
import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-urls.data.service'; import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-urls.data.service';
import { Group } from 'src/app/model/entities/group'; import { Group } from 'src/app/model/entities/group';
import { Domain } from 'src/app/model/entities/domain';
@Component({ @Component({
selector: 'app-short-url-form-page', selector: 'app-short-url-form-page',
@ -22,12 +23,16 @@ export class ShortUrlFormPageComponent extends FormPageBase<
ShortUrlsDataService ShortUrlsDataService
> { > {
groups: Group[] = []; groups: Group[] = [];
domains: Domain[] = [];
constructor(private toast: ToastService) { constructor(private toast: ToastService) {
super(); super();
this.dataService.getAllGroups().subscribe(groups => { this.dataService.getAllGroups().subscribe(groups => {
this.groups = groups; this.groups = groups;
}); });
this.dataService.getAllDomains().subscribe(domains => {
this.domains = domains;
});
if (!this.nodeId) { if (!this.nodeId) {
this.node = this.new(); this.node = this.new();
@ -62,6 +67,7 @@ export class ShortUrlFormPageComponent extends FormPageBase<
description: new FormControl<string | undefined>(undefined), description: new FormControl<string | undefined>(undefined),
loadingScreen: new FormControl<boolean | undefined>(undefined), loadingScreen: new FormControl<boolean | undefined>(undefined),
groupId: new FormControl<number | undefined>(undefined), groupId: new FormControl<number | undefined>(undefined),
domainId: new FormControl<number | undefined>(undefined),
}); });
this.form.controls['id'].disable(); this.form.controls['id'].disable();
} }
@ -86,6 +92,7 @@ export class ShortUrlFormPageComponent extends FormPageBase<
this.form.controls['description'].setValue(node?.description); this.form.controls['description'].setValue(node?.description);
this.form.controls['loadingScreen'].setValue(node?.loadingScreen); this.form.controls['loadingScreen'].setValue(node?.loadingScreen);
this.form.controls['groupId'].setValue(node?.group?.id); this.form.controls['groupId'].setValue(node?.group?.id);
this.form.controls['domainId'].setValue(node?.domain?.id);
} }
getCreateInput(): ShortUrlCreateInput { getCreateInput(): ShortUrlCreateInput {
@ -103,6 +110,9 @@ export class ShortUrlFormPageComponent extends FormPageBase<
groupId: this.form.controls['groupId'].pristine groupId: this.form.controls['groupId'].pristine
? undefined ? undefined
: (this.form.controls['groupId'].value ?? undefined), : (this.form.controls['groupId'].value ?? undefined),
domainId: this.form.controls['domainId'].pristine
? undefined
: (this.form.controls['domainId'].value ?? undefined),
}; };
} }
@ -128,6 +138,9 @@ export class ShortUrlFormPageComponent extends FormPageBase<
groupId: this.form.controls['groupId'].pristine groupId: this.form.controls['groupId'].pristine
? undefined ? undefined
: (this.form.controls['groupId'].value ?? undefined), : (this.form.controls['groupId'].value ?? undefined),
domainId: this.form.controls['domainId'].pristine
? undefined
: (this.form.controls['domainId'].value ?? undefined),
}; };
} }

View File

@ -20,6 +20,7 @@ import {
ShortUrlUpdateInput, ShortUrlUpdateInput,
} from 'src/app/model/entities/short-url'; } from 'src/app/model/entities/short-url';
import { Group } from 'src/app/model/entities/group'; import { Group } from 'src/app/model/entities/group';
import { Domain } from 'src/app/model/entities/domain';
@Injectable() @Injectable()
export class ShortUrlsDataService export class ShortUrlsDataService
@ -66,6 +67,10 @@ export class ShortUrlsDataService
id id
name name
} }
domain {
id
name
}
...DB_MODEL ...DB_MODEL
} }
@ -106,6 +111,10 @@ export class ShortUrlsDataService
id id
name name
} }
domain {
id
name
}
...DB_MODEL ...DB_MODEL
} }
@ -150,6 +159,7 @@ export class ShortUrlsDataService
description: object.description, description: object.description,
loadingScreen: object.loadingScreen, loadingScreen: object.loadingScreen,
groupId: object.groupId, groupId: object.groupId,
domainId: object.domainId,
}, },
}, },
}) })
@ -187,6 +197,7 @@ export class ShortUrlsDataService
description: object.description, description: object.description,
loadingScreen: object.loadingScreen, loadingScreen: object.loadingScreen,
groupId: object.groupId, groupId: object.groupId,
domainId: object.domainId,
}, },
}, },
}) })
@ -268,6 +279,29 @@ export class ShortUrlsDataService
.pipe(map(result => result.data.groups.nodes)); .pipe(map(result => result.data.groups.nodes));
} }
getAllDomains() {
return this.apollo
.query<{ domains: QueryResult<Domain> }>({
query: gql`
query getGroups {
domains {
nodes {
id
name
}
}
}
`,
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data.domains.nodes));
}
static provide(): Provider[] { static provide(): Provider[] {
return [ return [
{ {

View File

@ -22,6 +22,10 @@
<div class="pi pi-{{ url.loadingScreen ? 'check-circle' : 'times-circle' }}"></div> <div class="pi pi-{{ url.loadingScreen ? 'check-circle' : 'times-circle' }}"></div>
</span> </span>
</div> </div>
<div class="grid-container" *ngIf="url.domain">
<span class="grid-label font-bold">{{ 'common.domain' | translate }}:</span>
<span class="grid-value">{{ url.domain?.name }}</span>
</div>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<div class="flex"> <div class="flex">

View File

@ -30,6 +30,14 @@ export class SidebarService {
// trust me, you'll need this async // trust me, you'll need this async
async setElements() { async setElements() {
const elements: MenuElement[] = [ const elements: MenuElement[] = [
{
label: 'common.domains',
icon: 'pi pi-sitemap',
routerLink: ['/admin/domains'],
visible: await this.auth.hasAnyPermissionLazy([
PermissionsEnum.domains,
]),
},
{ {
label: 'common.groups', label: 'common.groups',
icon: 'pi pi-tags', icon: 'pi pi-tags',

View File

@ -25,6 +25,8 @@
"created": "Erstellt", "created": "Erstellt",
"deleted": "Gelöscht", "deleted": "Gelöscht",
"description": "Beschreibung", "description": "Beschreibung",
"domain": "Domain",
"domains": "Domains",
"download": "Herunterladen", "download": "Herunterladen",
"edited_at": "Bearbeitet am", "edited_at": "Bearbeitet am",
"editor": "Bearbeiter", "editor": "Bearbeiter",
@ -58,6 +60,9 @@
}, },
"save": "Speichern" "save": "Speichern"
}, },
"domain": {
"count_header": "Domain(s)"
},
"error": { "error": {
"404": "404 - Nicht gefunden", "404": "404 - Nicht gefunden",
"create_failed": "Erstellung fehlgeschlagen", "create_failed": "Erstellung fehlgeschlagen",

View File

@ -25,6 +25,8 @@
"created": "Created", "created": "Created",
"deleted": "Deleted", "deleted": "Deleted",
"description": "Description", "description": "Description",
"domain": "Domain",
"domains": "Domains",
"download": "Download", "download": "Download",
"edited_at": "Edited at", "edited_at": "Edited at",
"editor": "Editor", "editor": "Editor",
@ -58,6 +60,9 @@
}, },
"save": "Save" "save": "Save"
}, },
"domain": {
"count_header": "Domain(s)"
},
"error": { "error": {
"404": "404 - Not found", "404": "404 - Not found",
"create_failed": "Create failed", "create_failed": "Create failed",

View File

@ -18,7 +18,6 @@
background-color: $backgroundColor2; background-color: $backgroundColor2;
@layer utilities { @layer utilities {
.highlight { .highlight {
color: $textColorHighlight; color: $textColorHighlight;
@ -59,6 +58,10 @@
.deleted { .deleted {
color: $accentColor !important; color: $accentColor !important;
} }
.divider {
border-bottom: 1px solid $accentColor;
}
} }
h1, h1,
@ -67,6 +70,10 @@
color: $headerColor; color: $headerColor;
} }
input, .p-checkbox-box, .p-dropdown {
border: 1px solid $accentColor;
}
.app { .app {
.component { .component {
background-color: $backgroundColor; background-color: $backgroundColor;

View File

@ -18,7 +18,6 @@
background-color: $backgroundColor2; background-color: $backgroundColor2;
@layer utilities { @layer utilities {
.highlight { .highlight {
color: $textColorHighlight; color: $textColorHighlight;
@ -59,6 +58,10 @@
.deleted { .deleted {
color: $accentColor !important; color: $accentColor !important;
} }
.divider {
border-bottom: 1px solid $accentColor;
}
} }
h1, h1,
@ -67,6 +70,10 @@
color: $headerColor; color: $headerColor;
} }
input, .p-checkbox-box, .p-dropdown {
border: 1px solid $accentColor;
}
.app { .app {
.component { .component {
background-color: $backgroundColor; background-color: $backgroundColor;