doc platform

This commit is contained in:
ValueOn AG 2025-12-15 21:54:56 +01:00
parent cf4591f4dd
commit 6aff11090a
15 changed files with 683 additions and 1 deletions

Binary file not shown.

View file

@ -1 +0,0 @@
<mxfile host="Electron" modified="2025-12-07T12:20:24.090Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/20.3.0 Chrome/104.0.5112.114 Electron/20.1.3 Safari/537.36" etag="cr0QP01cfDrbV7QtecK0" version="20.3.0" type="device"><diagram name="Module Dependencies" id="module-dependencies">7Zpbc9o6EMc/DXOe2pHxBfKYQC+Z0860k/Zc+tIR0hq7CIvKcoB++koggS+iTVrHcOA4D0G7siz/f9JqZbvnj+arVwIvkrecAuv1EV31/HGv3/e8KFD/tGVtLeHV1jIVKTW2veEu/QbGiIy1SCnklYqScybTRdVIeJYBkRUbFoIvq9VizqpXXeApNAx3BLOm9e+UysRYA4T2jteQThN7aWQ9c2xrG0OeYMqXJZP/ouePBOdy+2u+GgHT8llhtue9PODd9UxAJh9ywugr//yJfi7e/3kl3/8lrpMP3tdn/W0r95gV5o6Nklzkuqltz+Xa6iF4kVHQLXo9/2aZpBLuFpho71INAWVL5JwZd5wyNuKMi825Png0hIGy51LwGZQ8V9HAx9HOY5Xu6zZ4Ju/M9T1b3g4TL1Bl038QElYHhfF2cquRCnwOUqxVFXPCDqYZpEFgyssScUsxKcGOjA2bQTbdNb3HoH4YEo+g4jWofLz9I28VBsUwjIkLRkSGMIlPBUa/QxY4JVxAQ3trbnc2xDFMAFwAJgOPeOQ3ADTUdjA5CGAfwdY1Il0gyEHcpwTyBoS9o+Wg5JOhukEHhht0FZY8j8Ggy6WW0OZoB8+wPj/8DumkmQQRYxefsqtdQhFGKI5dhPwBQqNRG4TizdEOofCY82f2NhjLCN++ng3XHwmhkyIunoUNHkBVlmOKGc90GKoi4kImfMozzN5wvjDGLyDl2qiIC8mr2JRiYv2PKiBb+FcXnoe2OF6VneO1KVWxUohxweSmFr3WGZwyThgnMydnVellymwXDtLLeSHILpKQQqRyKyuSWExBuoKP1uiHvAUwLNP7aqb4e8Gv3LNK8LOOy1iDomMGuRiwLIQjxO0dlx3gGhlC1CGdJRezmOl9XR1PyXPZKUKDT6fTRyktHZPHmttFg1CIwDl1EPLGNyc3deq5QZczR69nUYPLT3D8Qh7gXKTrK3l13f5SzBeWCBbk4St5eb0ur+TlnVz367i+4vBClK5vCY6jttd8YvJflrsu6mkNbe/cxvah/cBpjG2r7pnIXU2vT2n7tRE7uBCxa3n0kdQOz0rtmqanFbX7g0vR+hTiiN1xnYna5U1dWepqfDmS1OeV+x2S+jSSEf+8kpFDYldzwiNJ7V+I1MeN1s4X8E3pKZZ4rr9vaf0ZVhzqP9czrGhztPEMa3v0nuQNfeh4RT/o9HOJZs6eJ+p26P+k6k8bj03qAfm+aiVd5KC1TPBCGwnjBf05rCcQLHAM7cihV/BUejVz9ut3tz+QrAOJhrXZ31QodCjkP14hVdx/HLfxlT4y9F98Bw==</diagram></mxfile>

View file

@ -0,0 +1,683 @@
# Workflow Actions RBAC Integration Concept
## Übersicht
Dieses Dokument beschreibt das Konzept für die Umstrukturierung der Workflow Actions, um:
1. **RBAC-Integration** zu ermöglichen (Schutz von Actions über RESOURCE-Context)
2. **Strukturierte Parameter-Definitionen** statt Docstrings zu verwenden
3. **UI-Rendering-Typen** für Parameter zu definieren
4. **Keine Duplikation** von Parameter-Definitionen zu haben
5. **Plug-and-Play** Funktionalität beizubehalten
## Architektur-Konzept
### Grundprinzip: Deklarative Action-Definition
Ähnlich wie bei `aicore` Models, wo eine Struktur definiert wird und die Funktion ein Attribut ist, werden Actions jetzt deklarativ definiert:
```python
class MethodOutlook(MethodBase):
def __init__(self, services):
super().__init__(services)
self.name = "outlook"
self.description = "Handle Microsoft Outlook email operations"
# Actions werden deklarativ definiert
self._actions = {
"readEmails": ActionDefinition(
actionId="outlook.readEmails", # Für RBAC: RESOURCE context
description="Read emails from Outlook mailbox",
parameters={
"connectionReference": ActionParameter(
name="connectionReference",
type=str,
frontendType="select",
frontendOptions="user.connection",
required=True,
description="Microsoft connection label"
),
"query": ActionParameter(
name="query",
type=str,
frontendType="text",
required=False,
description="Search query for emails"
),
"folder": ActionParameter(
name="folder",
type=str,
frontendType="select",
frontendOptions="outlook.folder",
required=False,
description="Folder name (e.g., 'Inbox', 'Drafts')"
),
"limit": ActionParameter(
name="limit",
type=int,
frontendType="number",
required=False,
default=50,
description="Maximum number of emails to return"
)
},
execute=self._executeReadEmails # Funktion als Attribut
),
"sendEmail": ActionDefinition(
actionId="outlook.sendEmail",
description="Send email via Outlook",
parameters={...},
execute=self._executeSendEmail
)
}
async def _executeReadEmails(self, parameters: Dict[str, Any]) -> ActionResult:
"""Execute function - keine Parameter-Definition mehr hier"""
# Implementation...
```
## Globale Frontend-Type-Definition
**WICHTIG**: Frontend-Types werden zentral in `modules/shared/frontendTypes.py` definiert, nicht redundant pro Action.
Die globale `FrontendType` Enum enthält:
- **Standard Types**: `text`, `textarea`, `number`, `select`, `multiselect`, `checkbox`, `date`, `datetime`, `email`, `timestamp`, `json`, `multilingual`, `file`
- **Custom Types für Actions**: `userConnection`, `documentReference`, `workflowAction`
Custom-Types unterstützen dynamische Option-Listen über API-Endpoints:
- `userConnection``/api/options/user.connection` (Connections des aktuellen Users)
- `documentReference``/api/options/workflow.documentReference` (Document-Referenzen aus Workflow-Context)
- `workflowAction``/api/options/workflow.action` (Verfügbare Actions aus Workflow-Context)
## Datenmodelle
### ActionParameter
**WICHTIG**: Frontend-Types werden global definiert in `modules/shared/frontendTypes.py` und nicht redundant in Actions.
```python
from typing import Optional, Any, Union, List, Dict
from pydantic import BaseModel, Field
from modules.shared.frontendTypes import FrontendType # Globale Definition
class ActionParameter(BaseModel):
"""Parameter definition for an action"""
name: str = Field(description="Parameter name")
type: str = Field(description="Python type as string (e.g., 'str', 'int', 'bool', 'List[str]')")
frontendType: FrontendType = Field(description="UI rendering type (from global FrontendType enum)")
frontendOptions: Optional[Union[str, List[Dict[str, Any]]]] = Field(
None,
description="Options for select/multiselect/custom types. String reference (e.g., 'user.connection') or static list. For custom types like userConnection, this is automatically set to the API endpoint."
)
required: bool = Field(False, description="Whether parameter is required")
default: Optional[Any] = Field(None, description="Default value")
description: str = Field("", description="Parameter description")
validation: Optional[Dict[str, Any]] = Field(
None,
description="Validation rules (e.g., {'min': 1, 'max': 100})"
)
```
**Custom Frontend Types**:
- `FrontendType.USER_CONNECTION`: User connection selector - dynamische Options von `/api/options/user.connection`
- `FrontendType.DOCUMENT_REFERENCE`: Document reference selector - dynamische Options aus Workflow-Context
- `FrontendType.WORKFLOW_ACTION`: Workflow action selector - dynamische Options aus verfügbaren Actions
Für Custom-Types wird `frontendOptions` automatisch auf den entsprechenden API-Endpoint gesetzt (z.B. `"user.connection"`).
### ActionDefinition
```python
from typing import Dict, Callable, Awaitable
from pydantic import BaseModel, Field
class ActionDefinition(BaseModel):
"""Complete definition of an action"""
actionId: str = Field(
description="Unique action identifier for RBAC (format: 'module.actionName', e.g., 'outlook.readEmails')"
)
description: str = Field(description="Action description")
parameters: Dict[str, ActionParameter] = Field(
default_factory=dict,
description="Parameter definitions"
)
execute: Callable[[Dict[str, Any]], Awaitable[ActionResult]] = Field(
description="Execution function - async function that takes parameters dict and returns ActionResult"
)
# Optional metadata
category: Optional[str] = Field(None, description="Action category for grouping")
tags: List[str] = Field(default_factory=list, description="Tags for search/filtering")
```
## MethodBase Erweiterung
### Neue MethodBase Struktur
```python
class MethodBase:
"""Base class for all methods"""
def __init__(self, services: Any):
self.services = services
self.name: str
self.description: str
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
# Actions werden als Dictionary definiert
self._actions: Dict[str, ActionDefinition] = {}
# Nach Initialisierung: Actions registrieren
self._registerActions()
def _registerActions(self):
"""Register all actions defined in _actions"""
# Kann überschrieben werden für dynamische Registrierung
pass
@property
def actions(self) -> Dict[str, Dict[str, Any]]:
"""
Dynamically collect all actions from _actions dictionary.
Returns format compatible with existing system.
"""
result = {}
for actionName, actionDef in self._actions.items():
# RBAC-Check: Prüfe ob Action für aktuellen User verfügbar ist
if not self._checkActionPermission(actionDef.actionId):
continue # Skip if user doesn't have permission
# Konvertiere ActionDefinition zu altem Format für Kompatibilität
result[actionName] = {
'description': actionDef.description,
'parameters': self._convertParametersToOldFormat(actionDef.parameters),
'method': self._createActionWrapper(actionDef)
}
return result
def _checkActionPermission(self, actionId: str) -> bool:
"""
Check if current user has permission to execute this action.
Uses RBAC RESOURCE context.
"""
if not hasattr(self.services, 'rbac') or not self.services.rbac:
# Fallback: Allow if RBAC not available (backward compatibility)
return True
currentUser = self.services.chat.getCurrentUser()
if not currentUser:
return False
# RBAC-Check: RESOURCE context, item = actionId
permissions = self.services.rbac.getUserPermissions(
user=currentUser,
context=AccessRuleContext.RESOURCE,
item=actionId
)
return permissions.view
def _convertParametersToOldFormat(self, parameters: Dict[str, ActionParameter]) -> Dict[str, Dict[str, Any]]:
"""Convert ActionParameter dict to old format for compatibility"""
result = {}
for paramName, param in parameters.items():
result[paramName] = {
'type': param.type,
'required': param.required,
'description': param.description,
'default': param.default,
'frontendType': param.frontendType.value,
'frontendOptions': param.frontendOptions
}
return result
def _createActionWrapper(self, actionDef: ActionDefinition):
"""Create wrapper function that matches old action signature"""
async def wrapper(parameters: Dict[str, Any], *args, **kwargs):
# Parameter-Validierung basierend auf ActionParameter definitions
validatedParams = self._validateParameters(parameters, actionDef.parameters)
# Execute action
return await actionDef.execute(validatedParams, *args, **kwargs)
wrapper.is_action = True
return wrapper
def _validateParameters(self, parameters: Dict[str, Any], paramDefs: Dict[str, ActionParameter]) -> Dict[str, Any]:
"""Validate parameters against definitions"""
validated = {}
for paramName, paramDef in paramDefs.items():
value = parameters.get(paramName)
# Check required
if paramDef.required and value is None:
raise ValueError(f"Required parameter '{paramName}' is missing")
# Use default if not provided
if value is None and paramDef.default is not None:
value = paramDef.default
# Type validation
if value is not None:
value = self._validateType(value, paramDef.type)
# Custom validation rules
if paramDef.validation and value is not None:
self._applyValidationRules(value, paramDef.validation)
validated[paramName] = value
return validated
def _validateType(self, value: Any, expectedType: type) -> Any:
"""Validate and convert value to expected type"""
# Type validation logic...
if expectedType == int:
return int(value)
elif expectedType == str:
return str(value)
# ... weitere Typen
return value
def _applyValidationRules(self, value: Any, rules: Dict[str, Any]):
"""Apply custom validation rules"""
if 'min' in rules and value < rules['min']:
raise ValueError(f"Value must be >= {rules['min']}")
if 'max' in rules and value > rules['max']:
raise ValueError(f"Value must be <= {rules['max']}")
# ... weitere Validierungsregeln
```
## Migrationsstrategie
### Schritt 1: Neue Datenmodelle erstellen
**Datei**: `gateway/modules/datamodels/datamodelWorkflowActions.py`
```python
from typing import Optional, Any, Union, List, Dict, Callable, Awaitable
from enum import Enum
from pydantic import BaseModel, Field
from modules.datamodels.datamodelChat import ActionResult
from modules.shared.frontendTypes import FrontendType # Globale Definition verwenden
class ActionParameter(BaseModel):
"""Parameter definition for an action"""
name: str
type: str # String representation of type: "str", "int", "bool", "List[str]", etc.
frontendType: FrontendType
frontendOptions: Optional[Union[str, List[Dict[str, Any]]]] = None
required: bool = False
default: Optional[Any] = None
description: str = ""
validation: Optional[Dict[str, Any]] = None
class ActionDefinition(BaseModel):
"""Complete definition of an action"""
actionId: str # Format: "module.actionName" (e.g., "outlook.readEmails")
description: str
parameters: Dict[str, ActionParameter] = Field(default_factory=dict)
execute: Optional[Callable] = None # Will be set dynamically
category: Optional[str] = None
tags: List[str] = Field(default_factory=list)
```
### Schritt 2: MethodBase erweitern
**Datei**: `gateway/modules/workflows/methods/methodBase.py`
- Neue `_actions` Dictionary Property
- RBAC-Check Integration
- Parameter-Validierung
- Kompatibilität mit bestehendem System
### Schritt 3: Beispiel-Migration
**Vorher** (methodOutlook.py):
```python
@action
async def readEmails(self, parameters: Dict[str, Any]) -> ActionResult:
"""
GENERAL:
- Purpose: Read emails from Outlook mailbox
Parameters:
- connectionReference (str, required): Microsoft connection label.
- query (str, optional): Search query for emails.
- folder (str, optional): Folder name.
- limit (int, optional): Maximum number of emails. Default: 50.
"""
# Implementation...
```
**Nachher** (methodOutlook.py):
```python
def __init__(self, services):
super().__init__(services)
self.name = "outlook"
self.description = "Handle Microsoft Outlook email operations"
self._actions = {
"readEmails": ActionDefinition(
actionId="outlook.readEmails",
description="Read emails from Outlook mailbox",
parameters={
"connectionReference": ActionParameter(
name="connectionReference",
type="str",
frontendType=FrontendType.USER_CONNECTION, # Custom type - automatisch API-Endpoint
required=True,
description="Microsoft connection label"
),
"query": ActionParameter(
name="query",
type="str",
frontendType=FrontendType.TEXT,
required=False,
description="Search query for emails"
),
"folder": ActionParameter(
name="folder",
type="str",
frontendType=FrontendType.SELECT,
frontendOptions="outlook.folder",
required=False,
description="Folder name (e.g., 'Inbox', 'Drafts')"
),
"limit": ActionParameter(
name="limit",
type="int",
frontendType=FrontendType.NUMBER,
required=False,
default=50,
description="Maximum number of emails to return",
validation={"min": 1, "max": 1000}
)
},
execute=self._executeReadEmails
)
}
async def _executeReadEmails(self, parameters: Dict[str, Any]) -> ActionResult:
"""Execute function - keine Parameter-Definition mehr hier"""
# Implementation bleibt gleich...
```
## RBAC-Integration
### Action-IDs Format
Actions werden im RBAC-System als RESOURCE-Context Items behandelt:
- **Format**: `{moduleName}.{actionName}`
- **Beispiele**:
- `outlook.readEmails`
- `outlook.sendEmail`
- `sharepoint.uploadDocument`
- `ai.process`
### RBAC-Regeln für Actions
```json
{
"roleLabel": "user",
"context": "RESOURCE",
"item": "outlook.readEmails",
"view": true
}
```
```json
{
"roleLabel": "admin",
"context": "RESOURCE",
"item": "outlook",
"view": true
}
```
**Hierarchie**: Spezifische Action-Regeln überschreiben generische Module-Regeln.
### Bootstrap: Default RBAC Rules für Actions
In `interfaceBootstrap.py`:
```python
def initRbacRules(db: DatabaseConnector) -> None:
# ... existing rules ...
# Action Rules (RESOURCE context)
createActionRules(db)
def createActionRules(db: DatabaseConnector):
"""Create default RBAC rules for workflow actions"""
# SysAdmin: Access to all actions
db.recordCreate(AccessRule(
roleLabel="sysadmin",
context=AccessRuleContext.RESOURCE,
item=None, # All resources
view=True
))
# Admin: Access to all actions
db.recordCreate(AccessRule(
roleLabel="admin",
context=AccessRuleContext.RESOURCE,
item=None,
view=True
))
# User: Access to specific actions only
userActions = [
"outlook.readEmails",
"outlook.sendEmail",
"sharepoint.readDocuments",
"ai.process"
]
for actionId in userActions:
db.recordCreate(AccessRule(
roleLabel="user",
context=AccessRuleContext.RESOURCE,
item=actionId,
view=True
))
# Viewer: Read-only actions
viewerActions = [
"outlook.readEmails",
"sharepoint.readDocuments"
]
for actionId in viewerActions:
db.recordCreate(AccessRule(
roleLabel="viewer",
context=AccessRuleContext.RESOURCE,
item=actionId,
view=True
))
```
## Vorteile
### 1. Keine Duplikation
- Parameter werden nur einmal definiert (in `ActionDefinition`)
- Keine Docstring-Parsing mehr nötig
- Type-Safety durch Pydantic Models
### 2. RBAC-Integration
- Jede Action hat eine eindeutige ID für RBAC
- Granulare Kontrolle pro Action möglich
- Hierarchische Regeln (Module → Action)
### 3. UI-Rendering
- Frontend-Typen explizit definiert
- Options-Referenzen für dynamische Optionen
- Validierung auf Backend-Ebene
### 4. Plug-and-Play
- Actions bleiben als separate Method-Klassen
- Einfache Erweiterung durch neue Method-Klassen
- Kompatibilität mit bestehendem System
### 5. Type Safety
- Pydantic Models für Validierung
- Type-Hints für bessere IDE-Unterstützung
- Runtime-Validierung
## Migration Timeline
### Phase 1: Foundation (Woche 1)
- ✅ Datenmodelle erstellen (`datamodelWorkflowActions.py`)
- ✅ MethodBase erweitern
- ✅ RBAC-Integration in MethodBase
### Phase 2: Beispiel-Migration (Woche 2)
- 📝 Ein Method-Beispiel migrieren (z.B. `methodAi.py`)
- 📝 Tests schreiben
- 📝 Dokumentation aktualisieren
### Phase 3: Vollständige Migration (Woche 3-4)
- 📝 Alle Methods migrieren
- 📝 RBAC-Regeln in Bootstrap erstellen
- 📝 Frontend-Integration
### Phase 4: Testing & Cleanup (Woche 5)
- 📝 Unit Tests
- 📝 Integration Tests
- 📝 Performance Tests
- 📝 Alte Docstring-Parsing-Logik entfernen
## Offene Fragen
1. **Backward Compatibility**: Sollen alte Actions ohne `_actions` Dictionary weiterhin funktionieren?
- **Antwort**: Ja, MethodBase prüft zuerst `_actions`, dann fallback auf `@action` Decorator
2. **Parameter-Validierung**: Soll Validierung strikt sein oder tolerant?
- **Antwort**: Konfigurierbar pro Action
3. **Action-Discovery**: Sollen Actions zur Laufzeit registriert werden können?
- **Antwort**: Ja, über `_registerActions()` Methode
4. **Frontend-Integration**: Wie werden Actions im Frontend angezeigt?
- **Antwort**: API-Endpoint `/api/workflows/actions` liefert strukturierte Action-Definitionen
## API-Endpunkte
### GET /api/workflows/actions
Liefert alle verfügbaren Actions für den aktuellen User (gefiltert nach RBAC):
```json
{
"actions": [
{
"module": "outlook",
"actionId": "outlook.readEmails",
"name": "readEmails",
"description": "Read emails from Outlook mailbox",
"parameters": {
"connectionReference": {
"type": "str",
"frontendType": "userConnection",
"frontendOptions": "user.connection", # Automatisch für Custom-Types
"required": true,
"description": "Microsoft connection label"
},
"documentList": {
"type": "List[str]",
"frontendType": "documentReference",
"frontendOptions": "workflow.documentReference", # Automatisch für Custom-Types
"required": false,
"description": "Document list reference(s) from previous actions"
},
...
}
},
...
]
}
```
### GET /api/workflows/actions/{module}
Liefert Actions für ein spezifisches Modul.
### POST /api/workflows/actions/{module}/{action}/execute
Führt eine Action aus (mit RBAC-Check).
## Custom Frontend Types für Actions
### Verfügbare Custom Types
1. **`FrontendType.USER_CONNECTION`**
- **API-Endpoint**: `/api/options/user.connection`
- **Beschreibung**: Zeigt alle aktiven Connections des aktuellen Users
- **Verwendung**: Für Parameter wie `connectionReference` in Outlook/SharePoint Actions
- **Beispiel**:
```python
ActionParameter(
name="connectionReference",
type="str",
frontendType=FrontendType.USER_CONNECTION,
required=True,
description="Microsoft connection label"
)
```
2. **`FrontendType.DOCUMENT_REFERENCE`**
- **API-Endpoint**: `/api/options/workflow.documentReference` (zu implementieren)
- **Beschreibung**: Zeigt verfügbare Document-Referenzen aus dem aktuellen Workflow-Context
- **Verwendung**: Für Parameter wie `documentList` in Actions, die auf vorherige Action-Ergebnisse verweisen
- **Beispiel**:
```python
ActionParameter(
name="documentList",
type="List[str]",
frontendType=FrontendType.DOCUMENT_REFERENCE,
required=False,
description="Document list reference(s) from previous actions"
)
```
3. **`FrontendType.WORKFLOW_ACTION`**
- **API-Endpoint**: `/api/options/workflow.action` (zu implementieren)
- **Beschreibung**: Zeigt verfügbare Actions aus dem Workflow-Context
- **Verwendung**: Für Parameter, die auf andere Actions verweisen
### Custom Types erweitern
Neue Custom-Types können über `frontendTypes.py` registriert werden:
```python
from modules.shared.frontendTypes import FrontendType, registerCustomType
# Neuer Custom-Type hinzufügen
FrontendType.SHAREPOINT_FOLDER = "sharepointFolder"
# Registrieren
registerCustomType(
frontendType=FrontendType.SHAREPOINT_FOLDER,
optionsApiEndpoint="sharepoint.folder",
description={
"en": "SharePoint Folder",
"fr": "Dossier SharePoint",
"de": "SharePoint-Ordner"
}
)
```
### Frontend-Integration
Das Frontend muss:
1. Custom-Types erkennen (z.B. `frontendType === "userConnection"`)
2. Automatisch Options von `/api/options/{optionsName}` laden
3. Die Options als Select/Multiselect rendern
**Beispiel Frontend-Logik**:
```typescript
if (param.frontendType === 'userConnection') {
// Automatisch Options von /api/options/user.connection laden
const options = await fetch(`/api/options/${param.frontendOptions}`);
// Als Select rendern
}
```

Binary file not shown.

BIN
platform_overview.zip Normal file

Binary file not shown.

View file

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View file

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View file

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

View file

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

View file

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View file

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

View file

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

View file

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB