diff --git a/Src/TournamentCalendar/Configuration/Navigation.config b/Src/TournamentCalendar/Configuration/Navigation.config index b08f87d..9612f5a 100644 --- a/Src/TournamentCalendar/Configuration/Navigation.config +++ b/Src/TournamentCalendar/Configuration/Navigation.config @@ -12,6 +12,9 @@ + + + diff --git a/Src/TournamentCalendar/Controllers/Admin.cs b/Src/TournamentCalendar/Controllers/Admin.cs index 53659a1..c5b360e 100644 --- a/Src/TournamentCalendar/Controllers/Admin.cs +++ b/Src/TournamentCalendar/Controllers/Admin.cs @@ -6,9 +6,9 @@ namespace TournamentCalendar.Controllers; [Route(nameof(Admin))] public class Admin : ControllerBase { - private readonly Microsoft.Extensions.Hosting.IHostApplicationLifetime _applicationLifetime; + private readonly IHostApplicationLifetime _applicationLifetime; - public Admin(Microsoft.Extensions.Hosting.IHostApplicationLifetime appLifetime) + public Admin(IHostApplicationLifetime appLifetime) { _applicationLifetime = appLifetime; } diff --git a/Src/TournamentCalendar/Controllers/Auth.cs b/Src/TournamentCalendar/Controllers/Auth.cs index 223154f..5f56501 100644 --- a/Src/TournamentCalendar/Controllers/Auth.cs +++ b/Src/TournamentCalendar/Controllers/Auth.cs @@ -3,13 +3,14 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using TournamentCalendar.Models.AccountViewModels; +using TournamentCalendar.Services; using TournamentCalendar.Views; namespace TournamentCalendar.Controllers; public class Auth : ControllerBase { - public readonly IConfiguration _configuration; + private readonly IConfiguration _configuration; public Auth(IConfiguration configuration) { diff --git a/Src/TournamentCalendar/Controllers/Calendar.cs b/Src/TournamentCalendar/Controllers/Calendar.cs index fa72915..e36c728 100644 --- a/Src/TournamentCalendar/Controllers/Calendar.cs +++ b/Src/TournamentCalendar/Controllers/Calendar.cs @@ -4,6 +4,7 @@ using TournamentCalendar.Models.Calendar; using TournamentCalendar.Views; using TournamentCalendar.Data; +using TournamentCalendar.Services; namespace TournamentCalendar.Controllers; @@ -12,13 +13,15 @@ public class Calendar : ControllerBase { private readonly IMailMergeService _mailMergeService; private readonly string _domainName; + private readonly UserLocation _userLocation; private readonly ILogger _logger; private readonly IAppDb _appDb; - public Calendar(IWebHostEnvironment hostingEnvironment, IConfiguration configuration, IAppDb appDb, ILogger logger, IMailMergeService mailMergeService) : base(hostingEnvironment, configuration) + public Calendar(IWebHostEnvironment hostingEnvironment, IConfiguration configuration, IAppDb appDb, ILogger logger, IMailMergeService mailMergeService, UserLocationService locationService) : base(hostingEnvironment, configuration) { _mailMergeService = mailMergeService; _domainName = configuration["DomainName"]!; + _userLocation = locationService.GetLocation(); _appDb = appDb; _logger = logger; } @@ -33,8 +36,9 @@ public IActionResult Index(CancellationToken cancellationToken) public async Task All(CancellationToken cancellationToken) { ViewBag.TitleTagText = "Volleyball-Turnierkalender"; - var model = new BrowseModel(_appDb); + var model = new BrowseModel(_appDb, _userLocation); await model.Load(cancellationToken); + return View(ViewName.Calendar.Overview, model); } @@ -46,7 +50,7 @@ public async Task Id(long id, CancellationToken cancellationToken if (!ModelState.IsValid) return new StatusCodeResult(404); - var model = new BrowseModel(_appDb); + var model = new BrowseModel(_appDb, _userLocation); try { await model.Load(id, cancellationToken); @@ -138,7 +142,7 @@ public async Task Entry([FromForm] EditModel model, CancellationT Configuration.Bind(nameof(GoogleConfiguration), googleApi); await model.TryGetLongitudeLatitude(googleApi); model.Normalize(); - if (model.IsNew && User.Identity != null && User.Identity.IsAuthenticated) + if (model.IsNew && User.Identity is { IsAuthenticated: true }) { model.CreatedByUser = User.Identity.Name; } diff --git a/Src/TournamentCalendar/Controllers/ContentSynd.cs b/Src/TournamentCalendar/Controllers/ContentSynd.cs index 500de67..b856bab 100644 --- a/Src/TournamentCalendar/Controllers/ContentSynd.cs +++ b/Src/TournamentCalendar/Controllers/ContentSynd.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Primitives; using TournamentCalendar.Data; +using TournamentCalendar.Services; using TournamentCalendar.Views; namespace TournamentCalendar.Controllers; @@ -28,7 +29,7 @@ public async Task CalendarList([FromHeader(Name = "Referrer")] st // Cross Origin Request Sharing (CORS) - allow request from any domain: Response.Headers.Append("Access-Control-Allow-Origin", "*"); _logger.LogInformation("Host: {RemoteIp}, Referrer: {Referrer}", GetRemoteIpAddress(xForwardedFor, remoteAddr), referrer); - var model = new Models.Calendar.BrowseModel(_appDb); + var model = new Models.Calendar.BrowseModel(_appDb, new UserLocation(null, null)); await model.Load(cancellationToken); return PartialView(ViewName.ContentSynd.CalendarListPartial, model); } diff --git a/Src/TournamentCalendar/Controllers/GeoLocation.cs b/Src/TournamentCalendar/Controllers/GeoLocation.cs new file mode 100644 index 0000000..abfd09b --- /dev/null +++ b/Src/TournamentCalendar/Controllers/GeoLocation.cs @@ -0,0 +1,93 @@ +using TournamentCalendar.Data; +using TournamentCalendar.Services; +using TournamentCalendar.Views; +using TournamentCalendar.Models.GeoLocation; +using TournamentCalendar.Library; + +namespace TournamentCalendar.Controllers; + +/// +/// The GeoLocation controller is responsible for handling the user's location. +/// +[Route(nameof(GeoLocation))] +public class GeoLocation : ControllerBase +{ + private readonly UserLocationService _locationService; + private readonly IAppDb _appDb; + + /// + /// Initializes a new instance of the class. + /// + /// + /// + public GeoLocation(IAppDb appDb, UserLocationService locationService, IConfiguration configuration) + { + _appDb = appDb; + _locationService = locationService; + Configuration = configuration; + } + + [HttpGet("")] + public IActionResult Index() + { + ViewBag.TitleTagText = "Entfernungen anzeigen"; + var model = new EditModel(); + model.SetAppDb(_appDb); + return View(ViewName.GeoLocation.Index, model); + } + + /// + /// Sets the location from a user's GUID. + /// + /// + [HttpGet("location/{guid}")] + public IActionResult Location(Guid guid) + { + if (!ModelState.IsValid) + return RedirectToAction(nameof(Calendar.All), nameof(Controllers.Calendar)); + + _locationService.SetFromUserGuid(guid); + + return RedirectToAction(nameof(Calendar.All), nameof(Controllers.Calendar)); + } + + [HttpPost("location/{model}")] + public async Task Location([FromForm] EditModel model) + { + model.SetAppDb(_appDb); + + if (!ModelState.IsValid) + return View(ViewName.GeoLocation.Index, model); + + var googleApi = new GoogleConfiguration(); + Configuration.Bind(nameof(GoogleConfiguration), googleApi); + var userLocation = await model.TryGetLongitudeLatitude(googleApi); + _locationService.SetGeoLocation(userLocation); + + return RedirectToAction(nameof(GeoLocation.Index), nameof(Controllers.GeoLocation)); + } + + [HttpGet("location/{latitude}/{longitude}")] + public IActionResult Location(double latitude, double longitude) + { + if(!UserLocationService.IsValidLatitude(latitude)) + ModelState.AddModelError(nameof(latitude), "Latitude is invalid."); + + if (!UserLocationService.IsValidLongitude(longitude)) + ModelState.AddModelError(nameof(longitude), "Longitude is invalid."); + + if (!ModelState.IsValid) + _locationService.ClearGeoLocation(); + + _locationService.SetGeoLocation(latitude, longitude); + + return NoContent(); + } + + [HttpGet("clear")] + public IActionResult ClearLocation() + { + _locationService.ClearGeoLocation(); + return RedirectToAction(nameof(GeoLocation.Index), nameof(Controllers.GeoLocation)); + } +} diff --git a/Src/TournamentCalendar/Controllers/InfoService.cs b/Src/TournamentCalendar/Controllers/InfoService.cs index 0e95a86..31de2e7 100644 --- a/Src/TournamentCalendar/Controllers/InfoService.cs +++ b/Src/TournamentCalendar/Controllers/InfoService.cs @@ -3,6 +3,7 @@ using TournamentCalendar.Views; using TournamentCalendar.Library; using TournamentCalendar.Data; +using TournamentCalendar.Services; namespace TournamentCalendar.Controllers; @@ -13,10 +14,12 @@ public class InfoService : ControllerBase private readonly IMailMergeService _mailMergeService; private readonly ILogger _logger; private readonly IAppDb _appDb; + private readonly UserLocationService _locationService; - public InfoService(IAppDb appDb, IWebHostEnvironment hostingEnvironment, IConfiguration configuration, ILogger logger, IMailMergeService mailMergeService) : base(hostingEnvironment, configuration) + public InfoService(IAppDb appDb, IWebHostEnvironment hostingEnvironment, IConfiguration configuration, ILogger logger, UserLocationService locationService, IMailMergeService mailMergeService) : base(hostingEnvironment, configuration) { _appDb = appDb; + _locationService = locationService; _domainName = configuration["DomainName"]!; _mailMergeService = mailMergeService; _logger = logger; @@ -32,7 +35,9 @@ public IActionResult Index() public IActionResult Register() { ViewBag.TitleTagText = "Volley-News abonnieren"; - return View(ViewName.InfoService.Edit, new Models.InfoService.EditModel(_appDb) { EditMode = Models.InfoService.EditMode.New }); + var model = new Models.InfoService.EditModel { EditMode = Models.InfoService.EditMode.New }; + model.SetAppDb(_appDb); + return View(ViewName.InfoService.Edit, model); } [HttpGet(nameof(Entry))] @@ -50,7 +55,12 @@ public IActionResult Entry(string guid) return RedirectToAction(nameof(InfoService.Index), nameof(Controllers.InfoService)); } - var model = new Models.InfoService.EditModel(_appDb, guid) { EditMode = Models.InfoService.EditMode.Change }; + var model = new Models.InfoService.EditModel { EditMode = Models.InfoService.EditMode.Change }; + model.SetAppDb(_appDb, guid); + + if (!model.IsNew && !_locationService.GetLocation().IsSet) + _locationService.SetGeoLocation(new UserLocation(model.Latitude, model.Longitude)); + return model.IsNew // id not found ? RedirectToAction(nameof(InfoService.Index), nameof(Controllers.InfoService)) : View(ViewName.InfoService.Edit, model); @@ -62,23 +72,20 @@ public IActionResult Entry(string guid) [HttpPost(nameof(InfoService.Entry)), ValidateAntiForgeryToken] public async Task Entry([FromForm] Models.InfoService.EditModel model, CancellationToken cancellationToken) { - model = new Models.InfoService.EditModel(_appDb); - _ = await TryUpdateModelAsync(model); - ViewBag.TitleTagText = "Volley-News abonnieren"; + + model.SetAppDb(_appDb); + _ = await TryUpdateModelAsync(model); + model.EditMode = string.IsNullOrWhiteSpace(model.Guid) ? Models.InfoService.EditMode.New : Models.InfoService.EditMode.Change; - if (!ModelState.IsValid && model.ExistingEntryWithSameEmail != null) - { + if (!ModelState.IsValid && model.ExistingEntryWithSameEmail is { ConfirmedOn: null }) // if the entry with this email address was not yet confirmed, just redirect there - if (!model.ExistingEntryWithSameEmail.ConfirmedOn.HasValue) - { - return RedirectToAction(nameof(InfoService.Entry), nameof(Controllers.InfoService), new {guid = model.ExistingEntryWithSameEmail.Guid }); - } - - // todo: what to do, if the email was already confirmed? Re-send confirmation email without asking? + { + return RedirectToAction(nameof(InfoService.Entry), nameof(Controllers.InfoService), new {guid = model.ExistingEntryWithSameEmail.Guid }); } + // todo: what to do, if the email was already confirmed? Re-send confirmation email without asking? if (!ModelState.IsValid) { model.Normalize(ModelState); @@ -86,10 +93,12 @@ public async Task Entry([FromForm] Models.InfoService.EditModel m } ModelState.Clear(); - if (model.EditMode == Models.InfoService.EditMode.Change) - if (model.TryRefetchEntity()) - if (!await TryUpdateModelAsync(model)) - return View(ViewName.InfoService.Edit, model); + if (model.EditMode == Models.InfoService.EditMode.Change + && (!model.TryFetchEntity() + || !await TryUpdateModelAsync(model))) + { + return View(ViewName.InfoService.Edit, model); + } var googleApi = new GoogleConfiguration(); Configuration.Bind(nameof(GoogleConfiguration), googleApi); @@ -102,14 +111,18 @@ public async Task Entry([FromForm] Models.InfoService.EditModel m { HttpContext.Session.Remove(Axuno.Web.CaptchaSvgGenerator.CaptchaSessionKeyName); - if ((confirmationModel = await model.Save(cancellationToken)).SaveSuccessful) + confirmationModel = await model.Save(cancellationToken); + if (!confirmationModel.SaveSuccessful) + return View(ViewName.InfoService.Confirm, confirmationModel); + + if (confirmationModel.Entity?.UnSubscribedOn == null) { - if (confirmationModel.Entity?.UnSubscribedOn == null) - { - confirmationModel = await new Mailer(_mailMergeService, _domainName).MailInfoServiceRegistrationForm(confirmationModel, - Url.Action(nameof(Approve), nameof(Controllers.InfoService), new {guid = model.Guid})!, - Url.Action(nameof(Entry), nameof(Controllers.InfoService), new { guid = model.Guid})!); - } + if (model is { Latitude: not null, Longitude: not null }) + _locationService.SetGeoLocation(model.Latitude.Value, model.Longitude.Value); + + confirmationModel = await new Mailer(_mailMergeService, _domainName).MailInfoServiceRegistrationForm(confirmationModel, + Url.Action(nameof(Approve), nameof(Controllers.InfoService), new {guid = model.Guid})!, + Url.Action(nameof(Entry), nameof(Controllers.InfoService), new { guid = model.Guid})!); } return View(ViewName.InfoService.Confirm, confirmationModel); @@ -129,7 +142,7 @@ public async Task Unsubscribe([FromForm] Models.InfoService.EditM { if (!ModelState.IsValid) { - // We unregister the subsc + // We unregister the subscription } ViewBag.TitleTagText = "Volley-News abbestellen"; diff --git a/Src/TournamentCalendar/Controllers/Newsletter.cs b/Src/TournamentCalendar/Controllers/Newsletter.cs index ecea96c..d830abc 100644 --- a/Src/TournamentCalendar/Controllers/Newsletter.cs +++ b/Src/TournamentCalendar/Controllers/Newsletter.cs @@ -1,5 +1,6 @@ using TournamentCalendar.Data; using TournamentCalendar.Models.Newsletter; +using TournamentCalendar.Services; namespace TournamentCalendar.Controllers; @@ -7,16 +8,18 @@ namespace TournamentCalendar.Controllers; public class Newsletter : ControllerBase { private readonly IAppDb _appDb; + private readonly UserLocation _userLocation; - public Newsletter(IAppDb appDb) + public Newsletter(IAppDb appDb, UserLocationService locationService) { _appDb = appDb; + _userLocation = locationService.GetLocation(); } [HttpGet("show")] public async Task Show(CancellationToken cancellationToken) { - var model = await new NewsletterModel(_appDb).InitializeAndLoad(cancellationToken); + var model = await new NewsletterModel(_appDb, _userLocation).InitializeAndLoad(cancellationToken); return View("Show", model); } diff --git a/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Angle.cs b/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Angle.cs index 8391449..38a94e8 100644 --- a/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Angle.cs +++ b/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Angle.cs @@ -1,5 +1,4 @@ -using System; -using System.Globalization; +using System.Globalization; namespace Axuno.Tools.GeoSpatial; @@ -389,7 +388,7 @@ internal static NumberFormatInfo GetNumberFormatInfo(IFormatProvider? provider) numberFormat = provider.GetFormat(typeof(NumberFormatInfo)) as NumberFormatInfo; } - return numberFormat ?? CultureInfo.CurrentUICulture.NumberFormat; + return numberFormat ?? CultureInfo.CurrentUICulture.NumberFormat; } /// diff --git a/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/GoogleGeo.cs b/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/GoogleGeo.cs index 084800e..9b5eeaa 100644 --- a/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/GoogleGeo.cs +++ b/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/GoogleGeo.cs @@ -1,8 +1,5 @@ -using System; -using System.Globalization; -using System.Net.Http; +using System.Globalization; using System.Net.Http.Headers; -using System.Threading.Tasks; using System.Web; using System.Xml; @@ -148,7 +145,7 @@ private static async Task EvaluateResponse(HttpResponseMessage http switch (geoResponse.StatusText) { case null: - throw new Exception($"XML node {statusNode} not found"); + throw new InvalidOperationException($"XML node {statusNode} not found"); case "OK": geoResponse.Found = true; geoResponse.Success = true; @@ -182,7 +179,7 @@ private static GeoResponse GetLocation(GeoResponse geoResponse) if (latNode == null || lngNode == null || locTypeNode == null) { - throw new Exception("XML child nodes in /GeocodeResponse/result/geometry not found"); + throw new InvalidOperationException("XML child nodes in /GeocodeResponse/result/geometry not found"); } switch (locTypeNode.InnerText) diff --git a/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Location.Parser.cs b/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Location.Parser.cs index 990d746..c30db78 100644 --- a/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Location.Parser.cs +++ b/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Location.Parser.cs @@ -1,4 +1,5 @@ -using System.Text.RegularExpressions; +using System.Globalization; +using System.Text.RegularExpressions; namespace Axuno.Tools.GeoSpatial; diff --git a/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Location.cs b/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Location.cs index 8fe1b47..09db794 100644 --- a/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Location.cs +++ b/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Location.cs @@ -1,4 +1,5 @@ -using System.Text; +using System.Globalization; +using System.Text; using System.Xml; using System.Xml.Schema; using System.Xml.Serialization; @@ -201,7 +202,7 @@ public Angle Azimuth(Location point) /// /// Calculates the great circle distance, in meters, between this instance - /// and the specified value. + /// and the specified value, using the Haversine formula. /// /// The location of the other point. /// The great circle distance, in meters. diff --git a/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/LocationCollection.cs b/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/LocationCollection.cs index c88361e..d44680e 100644 --- a/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/LocationCollection.cs +++ b/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/LocationCollection.cs @@ -1,6 +1,5 @@ using System; using System.Collections; -using System.Collections.Generic; using System.Globalization; using System.Text; using System.Xml; diff --git a/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/LocationStyles.cs b/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/LocationStyles.cs index 7b675ef..e4e549b 100644 --- a/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/LocationStyles.cs +++ b/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/LocationStyles.cs @@ -1,6 +1,4 @@ -using System; - -namespace Axuno.Tools.GeoSpatial; +namespace Axuno.Tools.GeoSpatial; /// /// Determines the styles permitted in string arguments that are passed @@ -38,4 +36,4 @@ public enum LocationStyles /// and Iso styles are used. /// Any = Degrees | DegreesMinutes | DegreesMinutesSeconds | Iso -} \ No newline at end of file +} diff --git a/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Longitude.cs b/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Longitude.cs index 3ce0af7..6dbf7a2 100644 --- a/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Longitude.cs +++ b/Src/TournamentCalendar/Library/Axuno.Tools/GeoSpatial/Longitude.cs @@ -1,5 +1,4 @@ -using System; -using System.Globalization; +using System.Globalization; namespace Axuno.Tools.GeoSpatial; diff --git a/Src/TournamentCalendar/Models/Calendar/BrowseModel.cs b/Src/TournamentCalendar/Models/Calendar/BrowseModel.cs index c2f2d92..bb4a59e 100644 --- a/Src/TournamentCalendar/Models/Calendar/BrowseModel.cs +++ b/Src/TournamentCalendar/Models/Calendar/BrowseModel.cs @@ -1,4 +1,5 @@ using TournamentCalendar.Data; +using TournamentCalendar.Services; using TournamentCalendarDAL.EntityClasses; using TournamentCalendarDAL.HelperClasses; @@ -10,10 +11,12 @@ public class BrowseModel private readonly EntityCollection _tournaments = new(); private readonly EntityCollection _surfaces = new(); private readonly EntityCollection _playingAbilities = new(); + private readonly UserLocation _userLocation; - public BrowseModel(IAppDb appDb) + public BrowseModel(IAppDb appDb, UserLocation userLocation) { _appDb = appDb; + _userLocation = userLocation; } public async Task Load(CancellationToken cancellationToken) @@ -50,5 +53,10 @@ public async Task Load(long id, CancellationToken cancellationToken) public int Count => _tournaments.Count; public IEnumerable DisplayModel => - _tournaments.Select(t => new CalendarEntityDisplayModel(t, _surfaces, _playingAbilities)); + _tournaments.Select(t => new CalendarEntityDisplayModel(t, _userLocation, _surfaces, _playingAbilities)); + + public bool IsGeoSpacial() + { + return DisplayModel.Any(m => m.IsGeoSpatial()); + } } diff --git a/Src/TournamentCalendar/Models/Calendar/CalendarEntityDisplayModel.cs b/Src/TournamentCalendar/Models/Calendar/CalendarEntityDisplayModel.cs index 135af72..ab8d55b 100644 --- a/Src/TournamentCalendar/Models/Calendar/CalendarEntityDisplayModel.cs +++ b/Src/TournamentCalendar/Models/Calendar/CalendarEntityDisplayModel.cs @@ -1,19 +1,22 @@ using System.Web; using SD.LLBLGen.Pro.ORMSupportClasses; +using TournamentCalendar.Services; using TournamentCalendarDAL.EntityClasses; -using TournamentCalendarDAL.HelperClasses; namespace TournamentCalendar.Models.Calendar; public class CalendarEntityDisplayModel : CalendarEntity { - private readonly EntityCollection _surfaces; - private readonly EntityCollection _playingAbilities; + private const string LatLonFormat = "###.########"; + private readonly ICollection _surfaces; + private readonly ICollection _playingAbilities; + private readonly UserLocation _userLocation; - public CalendarEntityDisplayModel(IEntity2 t, EntityCollection surfaces, EntityCollection playingAbilities) + public CalendarEntityDisplayModel(IEntity2 t, UserLocation userLocation, ICollection surfaces, ICollection playingAbilities) { // Make a deep copy base.Fields = t.Fields.Clone(); + _userLocation = userLocation; _surfaces = surfaces; _playingAbilities = playingAbilities; } @@ -59,16 +62,15 @@ public string GetTournamentType() if (NumPlayersMale > 0 && NumPlayersFemale > 0 && NumPlayersMale + NumPlayersFemale == 6) return "Mixed"; if (NumPlayersMale > 0 && NumPlayersFemale > 0 && NumPlayersMale + NumPlayersFemale == 4) - return "Quattro-Mixed"; + return "4er-Mixed"; if (NumPlayersMale > 0 && NumPlayersFemale > 0 && NumPlayersMale + NumPlayersFemale == 2) - return "Duo-Mixed"; + return "2er-Mixed"; if (NumPlayersMale > 0 && NumPlayersFemale > 0) return "Mixed"; return string.Empty; } - - + public string GetTournamentTypeAndPlayers() { var tournamentType = GetTournamentType(); @@ -82,8 +84,7 @@ public string GetTournamentTypeAndPlayers() if (NumPlayersMale > 0 && NumPlayersFemale > 0 && IsMinPlayersFemale) return $"{tournamentType} - {NumPlayersMale + NumPlayersFemale} Spieler, mind. {NumPlayersMale} Herr{(NumPlayersMale > 1 ? "en" : "")}"; - return String.Format("{0} - {1} Spieler{2}", tournamentType, NumPlayersMale + NumPlayersFemale, - NumPlayersFemale > 0 && NumPlayersMale == 0 ? "innen" : ""); + return $"{tournamentType} - {NumPlayersMale + NumPlayersFemale} Spieler{(NumPlayersFemale > 0 && NumPlayersMale == 0 ? "innen" : "")}"; } public string GetPlayingAbility() @@ -92,7 +93,7 @@ public string GetPlayingAbility() var from = _playingAbilities.First(pa => pa.Strength == PlayingAbilityFrom).Description; var to = _playingAbilities.First(pa => pa.Strength == PlayingAbilityTo).Description; - if (PlayingAbilityTo == 0) // unbeschränkt + if (PlayingAbilityTo == 0) // unlimited to = string.Empty; if (from.Length > 0 && to.Length > 0) @@ -111,17 +112,17 @@ public string GetPlayingAbility() public string GetVenueAddress(int? maxChar = null) { - var completeAddr = (CountryId.Length > 0 ? CountryId + " " : string.Empty) + + var completeAddress = (CountryId.Length > 0 ? CountryId + " " : string.Empty) + (PostalCode.Length > 0 ? PostalCode + " " : string.Empty) + (City.Length > 0 ? City + ", " : string.Empty) + (Street.Length > 0 ? Street : string.Empty); - if (maxChar.HasValue && completeAddr.Length > maxChar.Value) + if (maxChar.HasValue && completeAddress.Length > maxChar.Value) { - return completeAddr.Substring(0, maxChar.Value - 3) + "..."; + return string.Concat(completeAddress.AsSpan(0, maxChar.Value - 3), "...".AsSpan()); } - return completeAddr; + return completeAddress; } public string GetVenueGoogleMapsLink() @@ -129,20 +130,29 @@ public string GetVenueGoogleMapsLink() if (!(Longitude.HasValue && Latitude.HasValue)) return string.Empty; - return string.Format("http://maps.google.de?q={0},{1}", - Latitude.Value.ToString("###.########", System.Globalization.CultureInfo.InvariantCulture), - Longitude.Value.ToString("###.########", System.Globalization.CultureInfo.InvariantCulture)); + if (_userLocation.IsSet) + { + return string.Format("https://maps.google.de/maps?saddr={0},{1}&daddr={2},{3}", + _userLocation.Latitude!.Value.ToString(LatLonFormat, CultureInfo.InvariantCulture), + _userLocation.Longitude!.Value.ToString(LatLonFormat, CultureInfo.InvariantCulture), + Latitude.Value.ToString(LatLonFormat, CultureInfo.InvariantCulture), + Longitude.Value.ToString(LatLonFormat, CultureInfo.InvariantCulture)); + } + + return string.Format("https://maps.google.de?q={0},{1}", + Latitude.Value.ToString(LatLonFormat, CultureInfo.InvariantCulture), + Longitude.Value.ToString(LatLonFormat, CultureInfo.InvariantCulture)); } public bool IsGeoSpatial() { - return Longitude.HasValue && Latitude.HasValue; + return Longitude.HasValue && Latitude.HasValue && _userLocation.IsSet; } public string GetOrganizerShort(int maxChar) { if (Organizer.Length > maxChar) - return Organizer.Substring(0, maxChar - 3) + "..."; + return string.Concat(Organizer.AsSpan(0, maxChar - 3), "...".AsSpan()); return Organizer; } @@ -150,33 +160,31 @@ public string GetOrganizerShort(int maxChar) public string GetTournamentNameShort(int maxChar) { if (TournamentName.Length > maxChar) - return TournamentName.Substring(0, maxChar - 3) + "..."; + return string.Concat(TournamentName.AsSpan(0, maxChar - 3), "...".AsSpan()); return TournamentName; } - public string GetVenueDistanceFromAugsburg() + public int? GetDistanceToVenue() { - if (!(Longitude.HasValue && Latitude.HasValue)) - return String.Empty; + if (!IsGeoSpatial()) + return null; - var augsburg = + var userLoc = new Axuno.Tools.GeoSpatial.Location( - new Axuno.Tools.GeoSpatial.Latitude(Axuno.Tools.GeoSpatial.Angle.FromDegrees(48.3666)), - new Axuno.Tools.GeoSpatial.Longitude(Axuno.Tools.GeoSpatial.Angle.FromDegrees(10.894103))); + new Axuno.Tools.GeoSpatial.Latitude(Axuno.Tools.GeoSpatial.Angle.FromDegrees(_userLocation.Latitude!.Value)), + new Axuno.Tools.GeoSpatial.Longitude(Axuno.Tools.GeoSpatial.Angle.FromDegrees(_userLocation.Longitude!.Value))); var venue = new Axuno.Tools.GeoSpatial.Location( - new Axuno.Tools.GeoSpatial.Latitude(Axuno.Tools.GeoSpatial.Angle.FromDegrees(Latitude.Value)), - new Axuno.Tools.GeoSpatial.Longitude(Axuno.Tools.GeoSpatial.Angle.FromDegrees(Longitude.Value))); + new Axuno.Tools.GeoSpatial.Latitude(Axuno.Tools.GeoSpatial.Angle.FromDegrees(Latitude!.Value)), + new Axuno.Tools.GeoSpatial.Longitude(Axuno.Tools.GeoSpatial.Angle.FromDegrees(Longitude!.Value))); - var distance = (int) augsburg.Distance(venue)/1000; - return distance < 1 - ? string.Empty - : string.Format("Entfernung nach Augsburg/Königsplatz ca. {0:0} km Luftlinie", distance); + var distance = (int) userLoc.Distance(venue)/1000; + return distance; } - public string GetContactAddr() + public string GetContactAddress() { if (ContactAddress.Length > 0) return Axuno.Tools.String.StringHelper.NewLineToBreak(HttpUtility.HtmlEncode(ContactAddress)); @@ -189,7 +197,7 @@ public string GetEntryFee() var fee = decimal.Compare(EntryFee, (decimal) 0.01) > 0 ? string.Format("{0} Euro", EntryFee) : "keine"; if (decimal.Compare(Bond, 0.01m) > 0) - fee += string.Format(" plus {0} Euro Kaution", Bond); + fee += $" plus {Bond} Euro Kaution"; return fee; } @@ -198,10 +206,9 @@ public string GetInfo() { return Axuno.Tools.String.StringHelper.NewLineToBreak(HttpUtility.HtmlEncode(Info)); } - public string GetSurface() { return _surfaces.First(s => s.Id == Surface).Description; } -} \ No newline at end of file +} diff --git a/Src/TournamentCalendar/Models/Calendar/EditModel.cs b/Src/TournamentCalendar/Models/Calendar/EditModel.cs index fc059c7..8f3933b 100644 --- a/Src/TournamentCalendar/Models/Calendar/EditModel.cs +++ b/Src/TournamentCalendar/Models/Calendar/EditModel.cs @@ -60,7 +60,7 @@ public bool TryRefetchEntity(CancellationToken cancellationToken) public async Task TryGetLongitudeLatitude(GoogleConfiguration googleConfig) { - if (Fields[CalendarFields.Street.FieldIndex].IsChanged || Fields[CalendarFields.PostalCode.FieldIndex].IsChanged || Fields[CalendarFields.City.FieldIndex].IsChanged) + if ((Latitude is null || Longitude is null) || Fields[CalendarFields.Street.FieldIndex].IsChanged || Fields[CalendarFields.PostalCode.FieldIndex].IsChanged || Fields[CalendarFields.City.FieldIndex].IsChanged) { try { @@ -261,7 +261,7 @@ public bool ShowTournament else { // Set deletion date, if tournament shall not be displayed - base.DeletedOn = value ? DateTime.Now : null; + base.DeletedOn = value ? null : DateTime.Now; } } } diff --git a/Src/TournamentCalendar/Models/GeoLocation/EditModel.cs b/Src/TournamentCalendar/Models/GeoLocation/EditModel.cs new file mode 100644 index 0000000..3439eef --- /dev/null +++ b/Src/TournamentCalendar/Models/GeoLocation/EditModel.cs @@ -0,0 +1,81 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.Rendering; +using TournamentCalendar.Data; +using TournamentCalendar.Library; +using TournamentCalendar.Services; +using TournamentCalendarDAL.EntityClasses; +using TournamentCalendarDAL.HelperClasses; + +namespace TournamentCalendar.Models.GeoLocation; + +public class EditModel : IValidatableObject +{ + private IAppDb? _appDb; + private readonly string[] _countryIds = new[] { "DE", "AT", "CH", "LI", "IT", "NL", "BE", "LU", "FR", "PL", "DK", "CZ", "SK" }; + + [Display(Name = "Land")] + public string? CountryId { get; set; } + + [Display(Name = "Postleitzahl")] + public string? ZipCode { get; set; } + + [Display(Name = "Ort")] + public string? City { get; set; } + + [Display(Name = "Straße")] + public string? Street { get; set; } + + public void SetAppDb(IAppDb appDb) + { + _appDb = appDb; + } + + public async Task> GetCountriesList() + { + var countries = new EntityCollection(); + await _appDb!.CountriesRepository.GetCountriesList(countries, _countryIds, CancellationToken.None); + + // add to countries list in the sequence of countryIds array + return _countryIds.Select(id => countries.First(c => c.Id == id)).Select( + item => new SelectListItem { Value = item.Id, Text = item.Name }).ToList(); + } + + public async Task TryGetLongitudeLatitude(GoogleConfiguration googleConfig) + { + try + { + // try to get longitude and latitude by Google Maps API + var completeAddress = string.Join(", ", ZipCode, City, Street); + CountryId ??= "DE"; + + var location = await Axuno.Tools.GeoSpatial.GoogleGeo.GetLocation(CountryId, completeAddress, + googleConfig.ServiceApiKey, TimeSpan.FromSeconds(15)); + if (location.GeoLocation.Latitude != null && location.GeoLocation.Latitude.Degrees > 1 && + location.GeoLocation.Longitude?.Degrees > 1) + { + return new UserLocation(location.GeoLocation.Latitude.TotalDegrees, + location.GeoLocation.Longitude.TotalDegrees); + } + } + catch (Exception) + { + // ignored + } + + return new UserLocation(null, null); + } + + public IEnumerable Validate(ValidationContext validationContext) + { + // Will be called only after individual fields are valid + var errors = new List(); + + if (CountryId == null || !_countryIds.ToList().Contains(CountryId)) + errors.Add(new ValidationResult("'Land' aus der Liste ist erforderlich", new[] {nameof(CountryId)})); + + if (string.IsNullOrEmpty(ZipCode) && string.IsNullOrEmpty(City)) + errors.Add(new ValidationResult("'Postleitzahl' oder 'Ort' sind erforderlich", new[] { nameof(ZipCode), nameof(City) })); + + return errors; + } +} diff --git a/Src/TournamentCalendar/Models/InfoService/EditModel.cs b/Src/TournamentCalendar/Models/InfoService/EditModel.cs index d5d8354..386c28e 100644 --- a/Src/TournamentCalendar/Models/InfoService/EditModel.cs +++ b/Src/TournamentCalendar/Models/InfoService/EditModel.cs @@ -19,7 +19,8 @@ public enum EditMode public class EditModel : InfoServiceEntity, IValidatableObject { private bool _isAddressEntered = true; - private readonly IAppDb? _appDb; + private IAppDb? _appDb; + internal readonly string[] CountryIds = new[] { "DE", "AT", "CH", "LI", "IT", "NL", "BE", "LU", "FR", "PL", "DK", "CZ", "SK" }; public EditModel() { @@ -32,13 +33,14 @@ public EditModel() IsAddressEntered = true; } - public EditModel(IAppDb appDb) : this() + public void SetAppDb(IAppDb appDb) { _appDb = appDb; } - public EditModel(IAppDb appDb, string guid) : this(appDb) + public void SetAppDb(IAppDb appDb, string guid) { + _appDb = appDb; LoadData(guid); IsAddressEntered = true; } @@ -48,7 +50,7 @@ private bool LoadData(string guid) return _appDb!.InfoServiceRepository.GetRegistrationByGuid(this, guid); } - public bool TryRefetchEntity() + public bool TryFetchEntity() { return string.IsNullOrWhiteSpace(Guid) || LoadData(Guid); } @@ -78,25 +80,6 @@ public async Task TryGetLongitudeLatitude(GoogleConfiguration googleConfig return true; } - public int GetDistanceToAugsburg() - { - if (Longitude.HasValue && Latitude.HasValue) - { - // Distance to Augsburg/Königsplatz - var augsburg = - new Axuno.Tools.GeoSpatial.Location(new Axuno.Tools.GeoSpatial.Latitude(Axuno.Tools.GeoSpatial.Angle.FromDegrees(48.3666)), - new Axuno.Tools.GeoSpatial.Longitude(Axuno.Tools.GeoSpatial.Angle.FromDegrees(10.894103))); - - var userLoc = - new Axuno.Tools.GeoSpatial.Location( - new Axuno.Tools.GeoSpatial.Latitude(Axuno.Tools.GeoSpatial.Angle.FromDegrees(Latitude.Value)), - new Axuno.Tools.GeoSpatial.Longitude(Axuno.Tools.GeoSpatial.Angle.FromDegrees(Longitude.Value))); - - return (int) (userLoc.Distance(augsburg) + 500)/1000; - } - return 0; - } - public IEnumerable GetGenderList() { return new List(3) @@ -104,7 +87,8 @@ public IEnumerable GetGenderList() new() {Value = "", Text = "Bitte wählen"}, // a value of string.Empty will cause a required validation error new() {Value = "f", Text = "Frau"}, - new() {Value = "m", Text = "Herr"} + new() {Value = "m", Text = "Herr"}, + new() {Value = "u", Text = "keine"} }; } @@ -134,13 +118,11 @@ public bool IsAddressEntered public async Task> GetCountriesList() { - var countryIds = new[] { "DE", "AT", "CH", "LI", "IT", "NL", "BE", "LU", "FR", "PL", "DK", "CZ", "SK" }; - var countries = new EntityCollection(); - await _appDb!.CountriesRepository.GetCountriesList(countries, countryIds, CancellationToken.None); + await _appDb!.CountriesRepository.GetCountriesList(countries, CountryIds, CancellationToken.None); // add to countries list in the sequence of countryIds array - return countryIds.Select(id => countries.First(c => c.Id == id)).Select( + return CountryIds.Select(id => countries.First(c => c.Id == id)).Select( item => new SelectListItem {Value = item.Id, Text = item.Name}).ToList(); } @@ -188,10 +170,7 @@ public void Normalize() } else { - CountryId = null; - ZipCode = City = Street = string.Empty; - Longitude = Latitude = null; - MaxDistance = null; + MaxDistance = 6300; } // if the email address is changed, it must be re-confirmed @@ -240,8 +219,8 @@ public void Normalize() return confirmModel; } - [Bind("TeamName, ClubName, Gender, Title, FirstName, LastName, Nickname, CountryId, ZipCode, City, Street, " + - "MaxDistance, Email, Guid, IsAddressEntered, Captcha" + [Bind("Gender, FirstName, LastName, CountryId, ZipCode, City, Street, " + + "Email, Guid, IsAddressEntered, Captcha" )] public class InfoServiceMetadata { @@ -257,43 +236,29 @@ public class InfoServiceMetadata [Display(Name="Anrede")] public string Gender { get; set; } = string.Empty; - [Display(Name="Titel")] - public string Title { get; set; } = string.Empty; - [Required(ErrorMessageResourceName = "PropertyValueRequired", ErrorMessageResourceType = typeof(DataAnnotationResource))] [Display(Name="Vorname")] public string FirstName { get; set; } = string.Empty; - [Display(Name="Ruf-/Spitzname")] - public string Nickname { get; set; } = string.Empty; - [Required(ErrorMessageResourceName = "PropertyValueRequired", ErrorMessageResourceType = typeof(DataAnnotationResource))] [Display(Name="Familienname")] public string LastName { get; set; } = string.Empty; - [Display(Name="Mannschaftsname")] - public string TeamName { get; set; } = string.Empty; - - [Display(Name="Vereinsname")] - public string ClubName { get; set; } = string.Empty; - [ValidateAddressFields("CountryId, ZipCode, City")] - [Display(Name="Angaben für die Entfernungsberechnung zum Veranstaltungsort aktivieren")] + [Display(Name="Entfernungsberechnung zum Veranstaltungsort aktivieren")] public bool IsAddressEntered { get; set; } - [Display(Name="Max. Entfernung")] - public int? MaxDistance { get; set; } - [Display(Name="Land")] - public string CountryId { get; set; } = string.Empty; + public string? CountryId { get; set; } [Display(Name="Postleitzahl")] - public string ZipCode { get; set; } = string.Empty; + public string? ZipCode { get; set; } [Display(Name="Ort")] - public string City { get; set; } = string.Empty; + public string? City { get; set; } - [Display(Name="Straße")] + [Display(Name = "Straße")] + [DisplayFormat(ConvertEmptyStringToNull = false)] public string Street { get; set; } = string.Empty; [Display(Name = "Ergebnis der Rechenaufgabe im Bild")] @@ -322,7 +287,7 @@ public IEnumerable Validate(ValidationContext validationContex // email address was found in a different record than the current one if (info.Guid != Guid) { - errors.Add(new ValidationResult(string.Format("E-Mail '{0}' ist bereits registriert", email), new[] { Email })); + errors.Add(new ValidationResult($"E-Mail '{email}' ist bereits registriert", new[] { Email })); // the controller will decide what to do now ExistingEntryWithSameEmail = info; @@ -337,14 +302,10 @@ public IEnumerable Validate(ValidationContext validationContex [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] public sealed class ValidateAddressFieldsAttribute : ValidationAttribute { - private readonly string[] _addressFieldNames; - public ValidateAddressFieldsAttribute(string addressFieldNames) { if (addressFieldNames == null) throw new ArgumentNullException(nameof(addressFieldNames)); - - _addressFieldNames = addressFieldNames.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries); } protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) @@ -352,26 +313,14 @@ public ValidateAddressFieldsAttribute(string addressFieldNames) if (value == null) return new ValidationResult(validationContext + " ist erforderlich"); - if ((bool)value) + if (value is true) // null, if IsAddressEntered is not true { - foreach (var addressFieldName in _addressFieldNames) - { - // get property of address field - var property = validationContext.ObjectType.GetProperty(addressFieldName); - - if (property == null) - return new ValidationResult(string.Format("Unbekannte Eigenschaft: {0}", addressFieldName)); - - // check types - if (property.PropertyType != typeof(string)) - return new ValidationResult(string.Format("Datentyp von Feld {0} muss 'string' sein", addressFieldName)); + var model = (EditModel) validationContext.ObjectInstance; - // get the field value - var field = (string?)property.GetValue(validationContext.ObjectInstance, null); - - if (field?.Trim().Length == 0) - return new ValidationResult("Felder für Entfernungsberechnung komplett füllen oder Entfernungs-Kontrollkästchen abwählen"); - } + if (model.CountryId == null || !model.CountryIds.ToList().Contains(model.CountryId) + || (string.IsNullOrWhiteSpace(model.ZipCode) && string.IsNullOrWhiteSpace(model.City))) + return new ValidationResult( + "Land, sowie Postleitzahl oder Ort ausfüllen oder Entfernungskontrollkästchen abwählen"); } return null; diff --git a/Src/TournamentCalendar/Models/Newsletter/NewsletterModel.cs b/Src/TournamentCalendar/Models/Newsletter/NewsletterModel.cs index 990b743..9734efd 100644 --- a/Src/TournamentCalendar/Models/Newsletter/NewsletterModel.cs +++ b/Src/TournamentCalendar/Models/Newsletter/NewsletterModel.cs @@ -3,6 +3,7 @@ using TournamentCalendarDAL.EntityClasses; using TournamentCalendarDAL.HelperClasses; using MailMergeLib; +using TournamentCalendar.Services; namespace TournamentCalendar.Models.Newsletter; @@ -12,10 +13,12 @@ public class NewsletterModel private readonly EntityCollection _tournaments = new(); private readonly EntityCollection _surfaces = new(); private readonly EntityCollection _playingAbilities = new(); + private readonly UserLocation _userLocation; - public NewsletterModel(IAppDb appDb) + public NewsletterModel(IAppDb appDb, UserLocation userLocation) { _appDb = appDb; + _userLocation = userLocation; } public async Task InitializeAndLoad(CancellationToken cancellationToken) @@ -61,7 +64,7 @@ private async Task LoadCalendarEntitiesSinceLastSend(CancellationToken cancellat public ICollection GetCalendarDisplayModel() => _tournaments - .Select(t => new CalendarEntityDisplayModel(t, _surfaces, _playingAbilities)).ToList(); + .Select(t => new CalendarEntityDisplayModel(t, _userLocation, _surfaces, _playingAbilities)).ToList(); public DateTime LastSendDate { get; private set; } diff --git a/Src/TournamentCalendar/Services/CookieService.cs b/Src/TournamentCalendar/Services/CookieService.cs new file mode 100644 index 0000000..f110b75 --- /dev/null +++ b/Src/TournamentCalendar/Services/CookieService.cs @@ -0,0 +1,89 @@ +namespace TournamentCalendar.Services; + +using Microsoft.AspNetCore.Http; + +/// +/// This service provides methods to get, set and remove cookies. +/// +public class CookieService : ICookieService +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public const string LocationCookieName = ".tc.loc"; + + /// + /// Initializes a new instance of the class. + /// Register as a scoped service. + /// + /// + /// + public CookieService(IHttpContextAccessor httpContextAccessor, ILogger logger) + { + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + /// + /// Gets the value of a cookie. + /// + /// + /// The value of the cookie or if not found. + public string? GetCookieValue(string cookieName) + { + if (!EnsureHttpContext()) + { + return null; + } + + _httpContextAccessor.HttpContext!.Request.Cookies.TryGetValue(cookieName, out var cookieValue); + return cookieValue; + } + + /// + /// Sets the value of a cookie. + /// + /// + /// + /// if , 1 year is used as default + /// if the cookie could be set. + public bool SetCookieValue(string cookieName, string cookieValue, TimeSpan? expireTime) + { + if (!EnsureHttpContext()) + { + return false; + } + + _httpContextAccessor.HttpContext!.Response.Cookies.Append(cookieName, cookieValue, new CookieOptions + { + Expires = expireTime.HasValue ? DateTime.Now.Add(expireTime.Value) : DateTime.Now.AddYears(1), + HttpOnly = true, IsEssential = true, Secure = true, SameSite = SameSiteMode.Strict + }); + + return true; + } + + /// + /// Removes a cookie. + /// + /// + public void RemoveCookie(string cookieName) + { + if (!EnsureHttpContext()) + { + return; + } + + _httpContextAccessor.HttpContext!.Response.Cookies.Delete(cookieName); + } + + private bool EnsureHttpContext() + { + if (_httpContextAccessor.HttpContext == null) + { + _logger.LogCritical("HttpContext is null"); + return false; + } + return true; + } +} diff --git a/Src/TournamentCalendar/Services/ICookieService.cs b/Src/TournamentCalendar/Services/ICookieService.cs new file mode 100644 index 0000000..60e7ede --- /dev/null +++ b/Src/TournamentCalendar/Services/ICookieService.cs @@ -0,0 +1,8 @@ +namespace TournamentCalendar.Services; + +public interface ICookieService +{ + string? GetCookieValue(string cookieName); + bool SetCookieValue(string cookieName, string cookieValue, TimeSpan? expireTime); + void RemoveCookie(string cookieName); +} diff --git a/Src/TournamentCalendar/Services/UserLocationService.cs b/Src/TournamentCalendar/Services/UserLocationService.cs new file mode 100644 index 0000000..03c336d --- /dev/null +++ b/Src/TournamentCalendar/Services/UserLocationService.cs @@ -0,0 +1,163 @@ +using TournamentCalendar.Data; +using TournamentCalendarDAL.EntityClasses; + +namespace TournamentCalendar.Services; + +/// +/// The user location. +/// +/// +/// +public record struct UserLocation(double? Latitude, double? Longitude) +{ + public bool IsSet => Latitude.HasValue + && UserLocationService.IsValidLatitude(Latitude.Value) + && Longitude.HasValue + && UserLocationService.IsValidLongitude(Longitude.Value); +} + +/// +/// The user location service is responsible for handling the user's location. +/// +public class UserLocationService +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ICookieService _cookieService; + private readonly IAppDb _appDb; + + private const string LatLonFormat = "###.########"; + public const string UserLocationSessionName = nameof(UserLocationSessionName); + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + public UserLocationService(IHttpContextAccessor httpContextAccessor, IAppDb appDb, ICookieService cookieService) + { + _httpContextAccessor = httpContextAccessor; + _appDb = appDb; + _cookieService = cookieService; + } + + /// + /// Sets the user's location. + /// + /// + /// + public void SetGeoLocation(double latitude, double longitude) + { + SetGeoLocation(new UserLocation(latitude, longitude)); + } + + /// + /// Sets the user's location if the is set, + /// otherwise clears the user's location. + /// + /// + public void SetGeoLocation(UserLocation userLocation) + { + if(!userLocation.IsSet) + { + ClearGeoLocation(); + return; + } + + var loc = Location2String(userLocation); + _httpContextAccessor.HttpContext?.Session.SetString(UserLocationSessionName, loc); + _cookieService.SetCookieValue(CookieService.LocationCookieName, loc, null); + } + + /// + /// Clears the user's location. + /// + public void ClearGeoLocation() + { + _httpContextAccessor.HttpContext?.Session.Remove(UserLocationSessionName); + _cookieService.RemoveCookie(CookieService.LocationCookieName); + } + + /// + /// Sets the user's location from the user's GUID. + /// + /// + public void SetFromUserGuid(Guid userGuid) + { + var infoService = new InfoServiceEntity(); + if (_appDb.InfoServiceRepository.GetRegistrationByGuid(infoService, userGuid.ToString("N")) + && infoService is { ConfirmedOn: not null, UnSubscribedOn: null, Latitude: not null, Longitude: not null }) + { + SetGeoLocation(infoService.Latitude.Value, infoService.Longitude.Value); + return; + } + + ClearGeoLocation(); + } + + /// + /// Gets the user's location from the session or the cookie. + /// + /// + public UserLocation GetLocation() + { + var userLocation = GetLocationFromSession(); + if (userLocation.IsSet) + { + return userLocation; + } + + if (_cookieService.GetCookieValue(CookieService.LocationCookieName) is { } cookieLocation + && TryString2Location(cookieLocation, out userLocation)) + { + SetGeoLocation(userLocation); + return userLocation; + } + + return new UserLocation(null, null); + } + + private UserLocation GetLocationFromSession() + { + if (_httpContextAccessor.HttpContext?.Session.GetString(UserLocationSessionName) is { } sessionLocation + && TryString2Location(sessionLocation, out var userLocation)) + { + return userLocation; + } + + return new UserLocation(null, null); + } + + private static bool TryString2Location(string location, out UserLocation userLocation) + { + var parts = location.Split('|'); + if (parts.Length == 2 && + double.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var latitude) && + double.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var longitude) && + IsValidLatitude(latitude) && IsValidLongitude(longitude)) + { + userLocation = new UserLocation(latitude, longitude); + return true; + } + + userLocation = new UserLocation(null, null); + return false; + } + + private static string Location2String(UserLocation userLocation) + { + return userLocation.IsSet + ? $"{userLocation.Latitude?.ToString(LatLonFormat, CultureInfo.InvariantCulture)}|{userLocation.Longitude?.ToString(LatLonFormat, CultureInfo.InvariantCulture)}" + : string.Empty; + } + + public static bool IsValidLatitude(double latDegrees) + { + return latDegrees is >= -90 and <= 90; + } + + public static bool IsValidLongitude(double lonDegrees) + { + return lonDegrees is >= -180 and <= 180; + } +} diff --git a/Src/TournamentCalendar/Views/Calendar/Overview.cshtml b/Src/TournamentCalendar/Views/Calendar/Overview.cshtml index f5300d9..7919cff 100644 --- a/Src/TournamentCalendar/Views/Calendar/Overview.cshtml +++ b/Src/TournamentCalendar/Views/Calendar/Overview.cshtml @@ -1,9 +1,11 @@ -@model TournamentCalendar.Models.Calendar.BrowseModel +@inject UserLocationService userLocation +@model TournamentCalendar.Models.Calendar.BrowseModel @using TournamentCalendar.Controllers +@using TournamentCalendar.Services @using TournamentCalendar.Views
-

Turnierkalender

+

@ViewBag.TitleTagText

Auf dieser Seite erscheinen primär Volleyball-Turniere aus dem deutschsprachigen Raum.

@@ -19,18 +21,31 @@ @* Hide on devices smaller than MD *@ Turniere
-
- @if (Model.DisplayModel.Any()) - { - var rec = Model.DisplayModel.Last(); - - } +
+
+ @if (Model.DisplayModel.Any()) + { + var rec = Model.DisplayModel.Last(); + + Filtern der Tabelle (z.B.: "@string.Join(string.Empty, rec.GetTournamentType().Take(5)).ToLower()" für @rec.GetTournamentType()-Turniere) + + } +
+
+ @if (!userLocation.GetLocation().IsSet) + { + + Entfernungen + + + Entfernungen anzeigen + + } +
- + @@ -38,25 +53,29 @@ + - - + + @foreach (var m in Model.DisplayModel) { - + var distance = m.GetDistanceToVenue(); + var distanceStyle = distance < 150 ? "font-weight:bold" : ""; + @**@ - + + } - -
Datum TurniernameArt Belag PLZkm
@m.DateFrom.ToString("dd.MM.yy") @m.GetTournamentNameShort(60)@m.GetOrganizerShort(20)@m.GetTournamentTypeShort() @m.GetSurface() @m.CountryId-@m.PostalCode@distance
-
Insgesamt @Model.DisplayModel.Count() Turniere im Kalender
+ + +
Insgesamt @Model.DisplayModel.Count() Turniere im Kalender
@section MetaSection { @@ -93,15 +112,22 @@ .datatable-table > thead > tr > th { padding: 8px 5px !important; } + + input[type=search]::-webkit-search-cancel-button { + -webkit-appearance: searchfield-cancel-button; + } } @section ScriptStandardSection { - - + +} \ No newline at end of file diff --git a/Src/TournamentCalendar/Views/InfoService/Edit.cshtml b/Src/TournamentCalendar/Views/InfoService/Edit.cshtml index ad90a2b..3179e8a 100644 --- a/Src/TournamentCalendar/Views/InfoService/Edit.cshtml +++ b/Src/TournamentCalendar/Views/InfoService/Edit.cshtml @@ -54,53 +54,43 @@ - - - - - - - - - - - -
- Ich möchte nur Turnierinformationen rund um folgenden Standort + Angaben zur Entfernungsberechnung vom Standort zum Turnierort
+
+ Für die Entfernungsberechnung kann eine beliebige Adresse verwendet werden. + Ohne Straßenangabe wird die geografische Ortsmitte verwendet. +
- +
- - - + - + - + - +
@@ -169,7 +159,6 @@ @section CssSection {} @section ScriptStandardSection { - @* Note: Google requires an API key for all requests since 11 June 2018 *@