serenity-is / Serenity

Business Apps Made Simple with Asp.Net Core MVC / TypeScript
https://serenity.is
MIT License
2.6k stars 802 forks source link

Where is the multitenancy tutorial source code? #2209

Closed BravoSierra closed 7 years ago

BravoSierra commented 7 years ago

Cannot find it here: https://github.com/volkanceylan/Serenity-Tutorials/tree/master/MultiTenancy How can we download this project? Thanks

brunobola commented 7 years ago

https://github.com/volkanceylan/serenity-guide/tree/master/tutorials/multi_tenancy

VR-Architect commented 7 years ago

Which version of Serenity are you using .NET 4.5 or CORE? I ran into an error deleting a user after finishing that guide for .NET 4.5 last night. Please let me know if you get the same?

volkanceylan commented 7 years ago

I am rewriting multitenancy tutorial at the moment for TypeScript. You can find source at https://github.com/volkanceylan/MultiTenancy

VR-Architect commented 7 years ago

Hi, Thanks for the tutorial. I have a cheat sheet I created to use when adding new objects. It may help others. I have attached it. After setting up multi-tenant, I follow the steps each time I add a new table. Hope this helps someone. Scott

On Wednesday, May 3, 2017, 4:34:26 PM EDT, Volkan Ceylan notifications@github.com wrote: I am rewriting multitenancy tutorial at the moment for TypeScript. You can find source at https://github.com/volkanceylan/MultiTenancy

— You are receiving this because you commented. Reply to this email directly, view it on GitHub, or mute the thread.

==== ADD NEW BO ====

  1. Create the data migration and run app, then stop app
  2. Run sergen
  3. Reload, rebuild, transform T4's
  4. Move navigation from *Page to Common/NavigationItems
  5. Edit Row 5a. Add permissions for permissions pick list to Row [ReadPermission("Administration:Functional")] [ModifyPermission("Administration:Functional")]

5b. Add IMultiTenantRow to *Row public sealed class LocationRow : Row, IIdRow, INameRow, IMultiTenantRow

5c. Add Tenant filters to *Row

    public Int32Field TenantIdField
    {
        get { return Fields.TenantId; }
    }

    [Insertable(false), Updatable(false)]
[DisplayName("Tenant Id"), NotNull]

5d. Add " readonly " to *Row at bottom class RowFields for every field with a TenantId public readonly Int32Field TenantId;

  1. Remove TenantId from Form and Columns

  2. Rebuild, Transform T4's, run, test

==== FOR LOOKUPS ====

  1. Add this to *Row of the object to lookup. The one being called. [JsonConverter(typeof(JsonRowConverter))] [LookupScript("luCountry")] public sealed class GenreRow : Row, IIdRow, INameRow

  2. DON'T DO THIS FOR DEV LOOKUPS AS THEY ARE NOT MULTI-TENANT ENABLED BY DESIGN!! Filter the dropdown for TenantId. Create a file in the folder of the called object called *Lookup.cs

namespace NET45.Default.Scripts { using Entities; using NET45.Modules.Default.Tenants; using Serenity.ComponentModel; using Serenity.Web;

[LookupScript("luLocation")] // Same as the *Row LookupScript name
public class LocationLookup : MultiTenantRowLookupScript<LocationRow> { }

}

  1. Add this to the Row of the object doing the lookup. The one doing the calling. Change the TextualField in Row to the fieldname you want to show the user in the dropdown [DisplayName("Country"), NotNull, ForeignKey("[core].[LocationCountry]", "LocationCountryId"), LeftJoin("jLocationCountry"), TextualField("LocationCountryCode")] [LookupEditor(typeof(LocationCountryRow), InplaceAdd = true, DialogType = "Default.LocationCountry")] public Int32? LocationCountryId { ... }

  2. Change what is shown in the grid columns *Columns for the calling object. Comment out the Id field ///public Int32 LocationCountryId { get; set; } [Width(100), QuickFilter, DisplayName("Country")] // TODO: Figure out how to use localization public String LocationCountryCode { get; set; }

  3. Rebuild, Transform T4's

============ SELF-REFERENCING TABLE WITH DRILL-DOWN IN GUI ================

  1. In *Row add Lookup Script to top and set permissions. [LookupScript("luLocation", Permission = PermissionKeys.Location)] public sealed class LocationRow : Row, IIdRow, INameRow, IMultiTenantRow

  2. In *Row add the Lookup Editor [DisplayName("Parent"), ForeignKey("[core].[Location]", "LocationId"), LeftJoin("jParentLocation"), TextualField("ParentLocationName")] [LookupInclude, LocationEditor] public Int32? ParentLocationId

  3. Add to *Grid.ts

    constructor(container: JQuery) {
        super(container);
    
        new Serenity.TreeGridMixin<LocationRow>({
            grid: this,
            getParentId: x => x.ParentLocationId,
            toggleField: LocationRow.Fields.Name,
            initialCollapse: () => false
        });
    }
    
    protected subDialogDataChange() {
        super.subDialogDataChange();
    
        Q.reloadLookup(LocationRow.lookupKey);
    }
    
    protected usePager() {
        return false;
    }
    
    protected getColumns() {
        var columns = super.getColumns();
    
        columns.splice(Q.indexOf(columns, x => x.name == LocationRow.Fields.Name) + 1, 0, {
            field: 'Add Child',
            name: '',
            format: ctx => '<a class="inline-action add-child-unit" title="add child"></a>',
            width: 24,
            minWidth: 24,
            maxWidth: 24
        });
    
        return columns;
    }
    
    protected onClick(e: JQueryEventObject, row: number, cell: number) {
        super.onClick(e, row, cell);
    
        if (e.isDefaultPrevented())
            return;
    
        var item = this.itemAt(row);
        var target = $(e.target);
    
        if (target.parent().hasClass('inline-action'))
            target = target.parent();
    
        if (target.hasClass('inline-action')) {
            e.preventDefault();
    
            if (target.hasClass('add-child-unit')) {
                var dlg = new LocationDialog();
                this.initDialog(dlg);
                dlg.loadEntityAndOpenDialog({
                    ParentLocationId: item.LocationId
                });
            }
        }
    }
  4. Create new file in the same directory called *Editor.ts and put this in: NOTE FILE EXTENTION!!!

namespace NET45.Default {

@Serenity.Decorators.registerEditor()
export class LocationEditor extends Serenity.LookupEditorBase<LocationRow, any> {

    constructor(hidden: JQuery) {
        super(hidden);
    }

    protected getLookupKey() {
        return LocationRow.lookupKey; // This will error until rebuild/transform T4's
    }

    protected getItemText(item: LocationRow, lookup: Q.Lookup<LocationRow>) {
        var visited = {};
        var text = item.Name;
        while (item.ParentUnitId != null && !visited[item.ParentUnitId]) {
            item = lookup.itemById[item.ParentUnitId];
            if (!item)
                break;
            visited[item.UnitId] = true;
            text = item.Name + " >> " + text;
        }

        return text;
    }
}

}

  1. Edit *Column:

    //public Int32 ParentLocationId { get; set; } [EditLink, Width(300), Visible(false), DisplayName("Parent")] public String ParentLocationName { get; set; }

  2. Change *Repository handlers:

    //private class MySaveHandler : SaveRequestHandler<MyRow> { }
    private class MySaveHandler : SaveRequestHandler<MyRow>
    {
        protected override void ValidateRequest()
        {
            base.ValidateRequest();
    
            if (IsUpdate && Old.ParentLocationId != Row.ParentLocationId && Row.ParentLocationId != null)
            {
                if (Row.ParentLocationId == Row.LocationId)
                    throw new ValidationError("Can't move an item under itself!");
    
                if (GetParents(Row.ParentLocationId.Value).Any(x => x == Row.LocationId.Value))
                    throw new ValidationError("Can't move an item under one of its children!");
            }
        }
    
        private List<int> GetParents(int id)
        {
            var parentById = Connection.List<MyRow>(q => q
                .Select(fld.LocationId)
                .Select(fld.ParentLocationId))
                .ToDictionary(x => x.LocationId, x => x.ParentLocationId);
    
            var visited = new HashSet<int>();
            var result = new List<int>();
            int? ParentId; // Don't change this variable name
            while (parentById.TryGetValue(id, out ParentId) &&
                ParentId != null &&
                !visited.Contains(ParentId.Value))
            {
                id = ParentId.Value;
                result.Add(id);
                visited.Add(id);
            }
    
            return result;
        }
    }
    
    //private class MyDeleteHandler : DeleteRequestHandler<MyRow> { }
    private class MyDeleteHandler : DeleteRequestHandler<MyRow>
    {
        protected override void ExecuteDelete()
        {
            try
            {
                base.ExecuteDelete();
            }
            catch (Exception e)
            {
                SqlExceptionHelper.HandleDeleteForeignKeyException(e);
                throw;
            }
        }   
    }
  3. transform, Rebuild, transform T4's, run, and test

============== CASCADING DROP-DOWNS =====================

  1. Make sure there is a lookup script on all *Row's for each drop-down [LookupScript("luLocationCountry")] public sealed class LocationCountryRow : Row...

  2. Add LookupEditor for the Id field in each Row. 2a.Add "LookupInclude" the *Row of each object being looked up's foreign key. EXCEPT the lowest level drop down. For example: not on city, but put on state and country.

    // IN THE LocationStateRow.cs FILE [DisplayName("Country"), NotNull, ForeignKey("[core].[LocationCountry]", "LocationCountryId"), LeftJoin("jLocationCountry"), TextualField("LocationCountryName"), LookupInclude] [LookupEditor(typeof(LocationCountryRow), InplaceAdd = true, DialogType = "Default.LocationCountry")] public Int32? LocationCountryId

2b.In the object containing the drop-down's *Row (aka LocationRow.cs) Add Cascade tag from country to state, state to city, but not on city

    [DisplayName("City"), ForeignKey("[core].[LocationCity]", "LocationCityId"), LeftJoin("jLocationCity"), TextualField("LocationCityName")]
    [LookupEditor(typeof(LocationCityRow), InplaceAdd = true, DialogType = "Default.LocationCity",
        CascadeFrom = "LocationStateId", CascadeField = "LocationStateId"), LookupInclude]
    public Int32? LocationCityId
...

    [DisplayName("State"), ForeignKey("[core].[LocationState]", "LocationStateId"), LeftJoin("jLocationState"), TextualField("LocationStateCode")]
    [LookupEditor(typeof(LocationStateRow), InplaceAdd = true, DialogType = "Default.LocationState",
        CascadeFrom = "LocationCountryId", CascadeField = "LocationCountryId"), LookupInclude]
    public Int32? LocationStateId
    ...

    [DisplayName("Country"), ForeignKey("[core].[LocationCountry]", "LocationCountryId"), LeftJoin("jLocationCountry"), TextualField("LocationCountryCode")]
    [LookupEditor(typeof(LocationCountryRow), InplaceAdd = true, DialogType = "Default.LocationCountry"), LookupInclude]
    public Int32? LocationCountryId
...
  1. R ebuild, transform T4's, run, and test

============== PARENT/CHILD AKA EXTENDED TABLE ================

  1. NEED TO FIGURE THIS OUT NEXT.

============== CENTRALIZED FILTER FOR RECORD-BASE PERMISSIONS ======

  1. NEED TO FIGURE THIS OUT NEXT.

============== NOTES ==========

BravoSierra commented 7 years ago

Thanks Volkan for the tuto and Scott for the Checklist

About the Checklist. My main concern with Serenity is to be able to re-create a project from scratch after a significant change in the DB that makes it difficult to update the code. So I try to groupe portions of code that I can easily isolate and copy/paste into a fresh install. Your cheat sheet if very interesting and will be usefull, thanks.

About using Multitenancy as a row filter: there is an option here. Using Multitenancy is probably the easy way, but another solution is to use the permissions and roles. We could imagine use cases with the MovieDB. If I want to provide a personnal movie management service in the Cloud, the multitenancy is best. If I want to filter movies on an adult/children basis, then the role/permission way is better. This is an important architectural decision that only Volkan can take.

VR-Architect commented 7 years ago

@BravoSierra Interesting you bring that up. I just sent an email to Volkan yesterday requesting a tutorial on how to set up a second level of filtering to restrict records based upon another table used for permissions. For example:

In this use case, there would be a table holding the permission mappings and a UI to set them. The key also is to do this securely like Volkan explains in the Developer Guide for Tenant section.

volkanceylan commented 7 years ago

MultiTenancy tutorial and its code is updated. I'll add region / customer based permission sample to premium.