feat: initialize HostBooking and ConveyorDispo code structure and document project processes and database schema
This commit is contained in:
30
03_Realisierung/Code Snippets/HostBooking/App.config
Normal file
30
03_Realisierung/Code Snippets/HostBooking/App.config
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0"?>
|
||||
<configuration>
|
||||
<appSettings>
|
||||
<!-- BEGINN Prozesseinstellungen -->
|
||||
<add key="Conn1" value="HostBooking"/>
|
||||
<!-- Telegrammverbuchung aufteilen nach der letzten Stelle der HU Nummer?-->
|
||||
<add key="UseLoadBalancing" value="false"/>
|
||||
<!--Polling Intervall der Producerklasse-->
|
||||
<add key="Intervall" value="201"/>
|
||||
<!-- so viele Telegramme bekommt ein Consumer maximal auf einen Schlag-->
|
||||
<add key="ConsumerQueueLength" value="200"/>
|
||||
<!-- Überprüfungsintervall -->
|
||||
<add key="ctrlTimer" value="12000" />
|
||||
<!-- ENDE Prozesseinstellungen-->
|
||||
|
||||
|
||||
<!-- BEGINN Log -->
|
||||
<!-- 0=ERROR 3=INFO 5=DEBUG 7=LOWLEVEL -->
|
||||
<add key="LogLevel" value="7"/>
|
||||
<!-- Aufteilung der Log-Files per Thread -->
|
||||
<add key="SplitLogFilesByThreadName" value="true"/>
|
||||
<add key="MainThreadNames" value="-"/>
|
||||
<!-- ENDE Log -->
|
||||
|
||||
|
||||
<!-- Datenbankverbindung -->
|
||||
<add key="Eigentuemer" value="Wcs" />
|
||||
|
||||
</appSettings>
|
||||
</configuration>
|
||||
45
03_Realisierung/Code Snippets/HostBooking/Exceptions.cs
Normal file
45
03_Realisierung/Code Snippets/HostBooking/Exceptions.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using Gebhardt.StoreWare.Wcs.HostBooking.InterfaceWcsWms;
|
||||
|
||||
namespace Gebhardt.StoreWare.Wcs.HostBooking
|
||||
{
|
||||
|
||||
public class LeNoMissingException : FromWmsException
|
||||
{
|
||||
public LeNoMissingException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
public class LeNoWrongFormatException : FromWmsException
|
||||
{
|
||||
public LeNoWrongFormatException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
public class LeNoTooShortException : FromWmsException
|
||||
{
|
||||
public LeNoTooShortException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
public class LeNoTooLongException : FromWmsException
|
||||
{
|
||||
public LeNoTooLongException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
public class TransportDestinationInvalidException : FromWmsException
|
||||
{
|
||||
public TransportDestinationInvalidException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
public class LeAlreadyInStorageException : FromWmsException
|
||||
{
|
||||
public LeAlreadyInStorageException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
public class LeNotReachableException : FromWmsException
|
||||
{
|
||||
public LeNotReachableException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
public class LeHasActiveTransportOrderException : FromWmsException
|
||||
{
|
||||
public LeHasActiveTransportOrderException(string message) : base(message) { }
|
||||
}
|
||||
}
|
||||
132
03_Realisierung/Code Snippets/HostBooking/Haupt.cs
Normal file
132
03_Realisierung/Code Snippets/HostBooking/Haupt.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using Gebhardt.Shared;
|
||||
using Gebhardt.Shared.Process;
|
||||
using Gebhardt.StoreWare.Wcs.Common.Unity;
|
||||
using Gebhardt.StoreWare.Wcs.HostBooking.InterfaceWcsWms;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Configuration;
|
||||
using Unity;
|
||||
|
||||
namespace Gebhardt.StoreWare.Wcs.HostBooking
|
||||
{
|
||||
internal class Haupt
|
||||
{
|
||||
[STAThread]
|
||||
private static void Main()
|
||||
{
|
||||
AppDomain.CurrentDomain.UnhandledException += HandleAppDomainException;
|
||||
try
|
||||
{
|
||||
IUnityContainer container = WcsContainerFactory.GetChildInstance();
|
||||
container.RegisterFromWmsHandlers();
|
||||
container.RegisterFromWmsServices();
|
||||
|
||||
if (AppConfigVerifier.CheckAndWriteToLog())
|
||||
{
|
||||
// LifeTimer Intervall ist in app.config ctrlTimer eingestellt
|
||||
int lifeTimerInterval = Convert.ToInt32(ConfigurationManager.AppSettings["ctrlTimer"]);
|
||||
string[] usedConnections = GetUsedConnections();
|
||||
|
||||
ProcessManager manager = new(lifeTimerInterval, true,
|
||||
ConfigurationManager.AppSettings["ProcessClass"] == "None" ? ProcessClass.None : ProcessClass.Application,
|
||||
usedConnections);
|
||||
//Worker (Producer und Consumer) erstellen und verknüpfen
|
||||
RegisterWorkers(manager, lifeTimerInterval, container);
|
||||
// Starten
|
||||
manager.RunWorkers();
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Log.WriteException(exception);
|
||||
Console.WriteLine("main thread: e: {0} e: {1}", exception.StackTrace, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void HandleAppDomainException(object sender, UnhandledExceptionEventArgs e)
|
||||
{
|
||||
Exception exception = (Exception)e.ExceptionObject;
|
||||
if (exception == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(exception));
|
||||
}
|
||||
|
||||
Log.WriteException(exception);
|
||||
Console.WriteLine($@"main thread: e: {exception.StackTrace} e: {exception}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ruft die Conn Einträge aus der App.config ab die nicht leer sind
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
private static string[] GetUsedConnections()
|
||||
{
|
||||
ProcessParameter parameter = new();
|
||||
var connections = new List<string> { parameter.Conn1, parameter.Conn2, parameter.Conn3, parameter.Conn4, parameter.Conn5, parameter.Conn6 };
|
||||
string[] usedConnections = connections.FindAll(x => x != "leer").ToArray();
|
||||
return usedConnections;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt einen Producer und je nach useLoadBalancing einen oder elf Consumer und weißt diese dem Producer und dem
|
||||
/// Manager zu.
|
||||
/// </summary>
|
||||
/// <param name="manager"></param>
|
||||
/// <param name="ctrTimerInterval">ctrTimer aus App.config</param>
|
||||
internal static void RegisterWorkers(ProcessManager manager, int ctrTimerInterval, IUnityContainer unityContainer)
|
||||
{
|
||||
//AppSettings auslesen
|
||||
bool useLoadBalancing;
|
||||
try
|
||||
{
|
||||
useLoadBalancing = Convert.ToBoolean(ConfigurationManager.AppSettings["UseLoadBalancing"]);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
useLoadBalancing = false;
|
||||
Log.Write(LogLevel.Error, "Parameter 'useLoadBalancing' fehlt in der App.config, setze auf false");
|
||||
}
|
||||
int consumerQueueLength;
|
||||
try
|
||||
{
|
||||
consumerQueueLength = Convert.ToInt32(ConfigurationManager.AppSettings["ConsumerQueueLength"]);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
consumerQueueLength = 200;
|
||||
Log.Write(LogLevel.Error, "Parameter 'consumerQueueLength' fehlt in der App.config, setze auf 200");
|
||||
}
|
||||
|
||||
// Producer anmelden
|
||||
int pollingInterval = Convert.ToInt32(ConfigurationManager.AppSettings["Intervall"]);
|
||||
HostBookingProducer producer = new(pollingInterval, useLoadBalancing, consumerQueueLength);
|
||||
manager.RegisterWorker(producer);
|
||||
string consumerName;
|
||||
HostBookingConsumer consumer;
|
||||
|
||||
//Wenn useLoadBalancing = true, dann wird für jede Endziffer der HU ein Consumer erstellt und einer für Telegramme ohne HU (Default)
|
||||
if (useLoadBalancing)
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
consumerName = $"Consumer_{i}";
|
||||
consumer = new HostBookingConsumer(consumerName, ctrTimerInterval / 2, consumerQueueLength, unityContainer);
|
||||
producer.AddConsumer(consumerName, consumer);
|
||||
manager.RegisterWorker(consumer);
|
||||
}
|
||||
consumerName = "Consumer_Default";
|
||||
consumer = new HostBookingConsumer(consumerName, ctrTimerInterval / 2, consumerQueueLength, unityContainer);
|
||||
producer.AddConsumer(consumerName, consumer);
|
||||
manager.RegisterWorker(consumer);
|
||||
}
|
||||
//ohne useLoadBalancing gibt es nur einen Consumer der alle Telegramme verarbeitet
|
||||
else
|
||||
{
|
||||
consumerName = "Consumer_All";
|
||||
consumer = new HostBookingConsumer(consumerName, ctrTimerInterval / 2, consumerQueueLength, unityContainer);
|
||||
producer.AddConsumer(consumerName, consumer);
|
||||
manager.RegisterWorker(consumer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Gebhardt.Shared;
|
||||
using Gebhardt.Shared.Process.ProducerConsumer;
|
||||
using Gebhardt.StoreWare.WcsWms.InterfaceWcsWms.Interfaces;
|
||||
using System;
|
||||
using Gebhardt.StoreWare.Wcs.HostBooking.InterfaceWcsWms.Interfaces;
|
||||
using Unity;
|
||||
using Gebhardt.StoreWare.Wcs.HostBooking.InterfaceWcsWms;
|
||||
|
||||
namespace Gebhardt.StoreWare.Wcs.HostBooking
|
||||
{
|
||||
public class HostBookingConsumer : Consumer<IHostMessage>
|
||||
{
|
||||
private readonly IUnityContainer _unityContainer;
|
||||
|
||||
public HostBookingConsumer(string name, int aliveTime, int queueLength, IUnityContainer unityContainer)
|
||||
: base(name, aliveTime, true, queueLength)
|
||||
{
|
||||
_unityContainer = unityContainer;
|
||||
}
|
||||
|
||||
public override bool DoWork(IHostMessage hostMessage)
|
||||
{
|
||||
IHostMessageFromWmsService service = _unityContainer.Resolve<IHostMessageFromWmsService>();
|
||||
|
||||
try
|
||||
{
|
||||
dynamic handler = _unityContainer.Resolve(typeof(IHandleRecord<>).MakeGenericType(hostMessage.GetType()), hostMessage.RecordType);
|
||||
Log.Write(LogLevel.Info, $"process message [{hostMessage}]");
|
||||
handler.Handle((dynamic)hostMessage);
|
||||
hostMessage.SetFinished();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (e.GetType() != typeof(FromWmsException))
|
||||
{
|
||||
Log.WriteException(e);
|
||||
}
|
||||
hostMessage.SetFailed(e.Message);
|
||||
}
|
||||
service.Update(hostMessage);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
203
03_Realisierung/Code Snippets/HostBooking/HostBookingProducer.cs
Normal file
203
03_Realisierung/Code Snippets/HostBooking/HostBookingProducer.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
using Gebhardt.Shared;
|
||||
using Gebhardt.Shared.Process.ProducerConsumer;
|
||||
using Gebhardt.StoreWare.WcsWms.Constants;
|
||||
using Gebhardt.StoreWare.WcsWms.InterfaceWcsWms.EntityFramework;
|
||||
using Gebhardt.StoreWare.WcsWms.InterfaceWcsWms.EntityFramework.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Gebhardt.StoreWare.WcsWms.InterfaceWcsWms.Interfaces;
|
||||
using Gebhardt.StoreWare.WcsWms.InterfaceWcsWms.Services;
|
||||
using static Gebhardt.StoreWare.Wcs.Common.ConstantsCommon.WatchdogConstants;
|
||||
|
||||
namespace Gebhardt.StoreWare.Wcs.HostBooking
|
||||
{
|
||||
internal class HostBookingProducer : Producer<IHostMessage>
|
||||
{
|
||||
private bool _firstExecution = true;
|
||||
|
||||
private int _consumerQueueLength;
|
||||
|
||||
private bool _useLoadBalancing;
|
||||
|
||||
private readonly IHostMessageFromWmsService _service = new HostMessageFromWmsService();
|
||||
|
||||
/// <summary>
|
||||
/// Fügt im Dictionary dem angegebenen Consumer das Messagem hinzu
|
||||
/// </summary>
|
||||
/// <param name="dataForConsumers"></param>
|
||||
/// <param name="consumer"></param>
|
||||
/// <param name="fromWms"></param>
|
||||
private void AddDataForConsumer(ref Dictionary<string, List<IHostMessage>> dataForConsumers, string consumer, IHostMessage fromWms)
|
||||
{
|
||||
if (dataForConsumers.ContainsKey(consumer))
|
||||
{
|
||||
dataForConsumers[consumer].Add(fromWms);
|
||||
}
|
||||
else
|
||||
{
|
||||
dataForConsumers.Add(consumer, new List<IHostMessage> { fromWms });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bestimmt den Consumer, der für den Datensatz verantwortlich ist
|
||||
/// </summary>
|
||||
/// <param name="consumersWithDemand"></param>
|
||||
/// <param name="criterion"></param>
|
||||
/// <returns>Consumer Name, wenn ein passender Consumer in der lsite ist, null, wenn kein passender Consumer in der Liste ist</returns>
|
||||
private string GetConsumerForBooking(List<string> consumersWithDemand, string criterion)
|
||||
{
|
||||
//Gibt es eine Letzte Stelle, sonst default
|
||||
if (!criterion.IsNullOrEmptyOrDbEmpty())
|
||||
{
|
||||
//Wir entscheiden mit der letzten Stelle der LE, welcher LE-Consumer verbucht oder ob der default Consumer das tun muss
|
||||
string endOfLe = criterion.Substring(criterion.Length - 1);
|
||||
//Der Name des Consumer endet mit dem gleichen Zeichen
|
||||
if (consumersWithDemand.Any(c => c.EndsWith(endOfLe)))
|
||||
{
|
||||
return consumersWithDemand.First(c => c.EndsWith(endOfLe));
|
||||
}
|
||||
//Soll dieses Messagem von einem speziellen Consumer bearbeitet werden? Aber dieser ist beschäftigt
|
||||
else if (Regex.IsMatch(endOfLe, "[0-9]"))
|
||||
{
|
||||
//Messagem auslassen - keinem Consumer zuordnen
|
||||
return null;
|
||||
}
|
||||
//HU endet mit einem Zeichen, dass zu keinem Consumer passt also Default oder auslassen
|
||||
else
|
||||
{
|
||||
//Default Consumer muss mit Default enden!
|
||||
return consumersWithDemand.FirstOrDefault(c => c.EndsWith("Default"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
//Default Consumer muss mit Default enden!
|
||||
return consumersWithDemand.FirstOrDefault(c => c.EndsWith("Default"));
|
||||
}
|
||||
}
|
||||
|
||||
protected override Dictionary<string, List<IHostMessage>> GetDataForConsumers(List<string> consumersWithDemand)
|
||||
{
|
||||
//TODO: jub evtl. die Messagem Stau Meldung aus KW übernehmen
|
||||
Dictionary<string, List<IHostMessage>> dataForConsumers = new Dictionary<string, List<IHostMessage>>();
|
||||
try
|
||||
{
|
||||
//using HostEntities db = new HostEntitiesFactory(HostEntities.DefaultConnectionStringName).Create();
|
||||
if (consumersWithDemand.Count > 0)
|
||||
{
|
||||
List<IHostMessage> fromWmsEntries;
|
||||
|
||||
if (_firstExecution)
|
||||
{
|
||||
//beim ersten mal nach Neustart werden die InProgress Messages nochmal auf Pending zurückgesetzt
|
||||
//Ref == null damit nur Kopfnachrichten gefunden werden
|
||||
fromWmsEntries = _service.GetAllEntries(t => t.Status == Status.InProgress).ToList();
|
||||
using HostEntities db = new HostEntitiesFactory(HostEntities.DefaultConnectionStringName).Create();
|
||||
fromWmsEntries.ForEach(message =>
|
||||
{
|
||||
FromWms fromWms = HostMessageFromWmsService.HostMapper.Map<FromWms>(message);
|
||||
db.Attach(fromWms);
|
||||
fromWms.Status = Status.Pending;
|
||||
});
|
||||
db.SaveChanges();
|
||||
Log.Write(LogLevel.Info, $"Erste Produce Schleife nach Neustart, setze {fromWmsEntries.Count} Telegramme zur Sicherheit nochmals von InProgress auf Pending");
|
||||
_firstExecution = false;
|
||||
}
|
||||
|
||||
//nur Messages, die noch an keinen Consumer gegeben wurden
|
||||
//es werden maximal so viele Messages abgerufen, wie ein einzelner Consumer annehmen könnte, damit das TryAdd sicher klappt und der Status nicht fälschlich
|
||||
//gesetzt wird
|
||||
//Ref == null damit nur Kopfnachrichten gefunden werden
|
||||
fromWmsEntries = _service.GetAllEntries(a => a.Status == Status.Pending).OrderBy(a => a.Id).Take(_consumerQueueLength).ToList();// db.FromWms.Where(a => a.Ref == null).Where(a => a.Status == Status.Pending).OrderBy(a => a.Id).Take(_consumerQueueLength).ToList();
|
||||
|
||||
|
||||
if (fromWmsEntries.Any())
|
||||
{
|
||||
using HostEntities db = new HostEntitiesFactory(HostEntities.DefaultConnectionStringName).Create();
|
||||
//ohne LoadBalancing erhält der erste Consumer alle Messages zum Verbuchen
|
||||
if (!_useLoadBalancing)
|
||||
{
|
||||
//der Consumer muss also alle Messages verarbeiten und nicht nur loggen
|
||||
dataForConsumers.Add(consumersWithDemand.FirstOrDefault() ?? string.Empty, fromWmsEntries);
|
||||
//Status auf InProgress damit jede Message nur einmal abgerufen wird
|
||||
fromWmsEntries.ForEach(message => {
|
||||
FromWms fromWms = HostMessageFromWmsService.HostMapper.Map<FromWms>(message);
|
||||
db.Attach(fromWms);
|
||||
fromWms.Status = Status.InProgress;
|
||||
});
|
||||
Log.Write(LogLevel.Low, $"Kein LoadBalancing aktiv, {fromWmsEntries.Count} neue HostMessages für Consumer {consumersWithDemand.FirstOrDefault()} gefunden");
|
||||
}
|
||||
//mit LoadBalancing wird nach der letzen Ziffer der ersten HU der Message auf 10 Consumer verteilt,
|
||||
//enthält die Message keine HU wird es an Consumer 11 übergeben.
|
||||
else
|
||||
{
|
||||
Log.Write(LogLevel.Low, $"LoadBalancing aktiv, {fromWmsEntries.Count} neue HostMessages gefunden, verteile auf Consumer");
|
||||
|
||||
foreach (IHostMessage message in fromWmsEntries)
|
||||
{
|
||||
try
|
||||
{
|
||||
FromWms fromWms = HostMessageFromWmsService.HostMapper.Map<FromWms>(message);
|
||||
db.Attach(fromWms);
|
||||
Log.Write(LogLevel.Low, $"Producerschleife für {fromWms}");
|
||||
string consumer;
|
||||
//keine HU in der Message, dann dem Default Consumer zuordnen
|
||||
if (fromWms.LeNo.IsNullOrEmptyOrDbEmpty())
|
||||
{
|
||||
consumer = GetConsumerForBooking(consumersWithDemand, null);
|
||||
//wenn der Default Consumer nicht in der Liste war, Message auslassen
|
||||
if (consumer != null)
|
||||
{
|
||||
AddDataForConsumer(ref dataForConsumers, consumer, message);
|
||||
//Status auf InProgress damit die Message nur einmal abgerufen wird
|
||||
fromWms.Status = Status.InProgress;
|
||||
}
|
||||
}
|
||||
//Messages mit HU
|
||||
else
|
||||
{
|
||||
consumer = GetConsumerForBooking(consumersWithDemand, fromWms.LeNo);
|
||||
AddDataForConsumer(ref dataForConsumers, consumer, message);
|
||||
//Status auf InProgress damit die Message nur einmal abgerufen wird
|
||||
fromWms.Status = Status.InProgress;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteException(ex);
|
||||
}
|
||||
}
|
||||
Log.Write(LogLevel.Low, $"Producerschleife beendet");
|
||||
}
|
||||
//Status Updates für alle weitergereichten Messages
|
||||
db.SaveChanges();
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Write(LogLevel.Debug, 60, "Keine Messages in FromWms");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Write(LogLevel.Debug, 60, "Kein Consumer hat demand");
|
||||
}
|
||||
|
||||
return dataForConsumers;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.WriteException(e);
|
||||
return dataForConsumers;
|
||||
}
|
||||
}
|
||||
|
||||
public HostBookingProducer(int workinterval, bool useLoadBalancing, int consumerQueueLength) : base(typeof(HostBookingProducer).Name, workinterval, true)
|
||||
{
|
||||
_consumerQueueLength = consumerQueueLength;
|
||||
_useLoadBalancing = useLoadBalancing;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
03_Realisierung/Code Snippets/HostBooking/Settings.cs
Normal file
28
03_Realisierung/Code Snippets/HostBooking/Settings.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace Gebhardt.StoreWare.Wcs.HostBooking.Properties {
|
||||
|
||||
|
||||
// This class allows you to handle specific events on the settings class:
|
||||
// The SettingChanging event is raised before a setting's value is changed.
|
||||
// The PropertyChanged event is raised after a setting's value is changed.
|
||||
// The SettingsLoaded event is raised after the setting values are loaded.
|
||||
// The SettingsSaving event is raised before the setting values are saved.
|
||||
internal sealed partial class Settings {
|
||||
|
||||
public Settings() {
|
||||
// // To add event handlers for saving and changing settings, uncomment the lines below:
|
||||
//
|
||||
// this.SettingChanging += this.SettingChangingEventHandler;
|
||||
//
|
||||
// this.SettingsSaving += this.SettingsSavingEventHandler;
|
||||
//
|
||||
}
|
||||
|
||||
private void SettingChangingEventHandler(object sender, System.Configuration.SettingChangingEventArgs e) {
|
||||
// Add code to handle the SettingChangingEvent event here.
|
||||
}
|
||||
|
||||
private void SettingsSavingEventHandler(object sender, System.ComponentModel.CancelEventArgs e) {
|
||||
// Add code to handle the SettingsSaving event here.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user