cache
parent
f9bf88c55f
commit
b61112d1e3
|
@ -0,0 +1,25 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
var accountCache = accountCacheType{
|
||||||
|
obj: map[string]*Account{},
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountCacheType struct {
|
||||||
|
sync.RWMutex
|
||||||
|
obj map[string]*Account
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *accountCacheType) get(recordID string) (*Account, bool) {
|
||||||
|
m.RLock()
|
||||||
|
defer m.RUnlock()
|
||||||
|
r, ok := m.obj[recordID]
|
||||||
|
return r, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *accountCacheType) put(recordID string, itm *Account) {
|
||||||
|
m.Lock()
|
||||||
|
defer m.Unlock()
|
||||||
|
m.obj[recordID] = itm
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"code.tnxs.net/taxnexus/lib/api/crm/crm_client/accounts"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetAccount(key string, principal *User) Account {
|
||||||
|
if key == "" {
|
||||||
|
return Account{}
|
||||||
|
}
|
||||||
|
a, ok := accountCache.get(key)
|
||||||
|
if ok {
|
||||||
|
return *a
|
||||||
|
}
|
||||||
|
acct, err := GetAccountByID(key, principal)
|
||||||
|
if err != nil {
|
||||||
|
return Account{}
|
||||||
|
}
|
||||||
|
return acct
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAccountByID(recordID string, principal *User) (Account, error) {
|
||||||
|
sugar.Debug("app.GetAccountByID: 📥")
|
||||||
|
if recordID == "" {
|
||||||
|
return Account{}, fmt.Errorf("app.getAccountByID: 💣 ⛔ key is blank")
|
||||||
|
}
|
||||||
|
obj, ok := accountCache.get(recordID)
|
||||||
|
if ok {
|
||||||
|
sugar.Debug("app.getAccountByID: 👍 🎯 📤")
|
||||||
|
return *obj, nil
|
||||||
|
}
|
||||||
|
crmParams := accounts.NewGetAccountsParamsWithTimeout(getTimeout)
|
||||||
|
crmParams.AccountID = &recordID
|
||||||
|
response, err := crmClient.Accounts.GetAccounts(crmParams, principal.Auth)
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, err
|
||||||
|
}
|
||||||
|
var newObj *Account
|
||||||
|
for _, itm := range response.Payload.Data { // single iteration execution
|
||||||
|
newObj = UnMarshalAccount(itm)
|
||||||
|
}
|
||||||
|
accountCache.put(recordID, newObj)
|
||||||
|
sugar.Debug("app.getAccountByID: 👍 🆕 📤")
|
||||||
|
return *newObj, nil
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"code.tnxs.net/taxnexus/lib/api/geo/geo_models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var countyCache = countyCacheType{
|
||||||
|
obj: map[string]*geo_models.County{},
|
||||||
|
}
|
||||||
|
|
||||||
|
type countyCacheType struct {
|
||||||
|
sync.RWMutex
|
||||||
|
obj map[string]*geo_models.County
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countyCacheType) get(key string) (*geo_models.County, bool) {
|
||||||
|
m.RLock()
|
||||||
|
defer m.RUnlock()
|
||||||
|
r, ok := m.obj[key]
|
||||||
|
return r, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countyCacheType) put(key string, obj *geo_models.County) {
|
||||||
|
m.Lock()
|
||||||
|
defer m.Unlock()
|
||||||
|
m.obj[key] = obj
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"code.tnxs.net/taxnexus/lib/api/geo/geo_client/county"
|
||||||
|
"code.tnxs.net/taxnexus/lib/api/geo/geo_models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetCountyNameByGeocode(key string, principal *User) string {
|
||||||
|
if key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(key) == countyGeocodeLength {
|
||||||
|
c, ok := countyCache.get(key)
|
||||||
|
if ok {
|
||||||
|
return c.Name
|
||||||
|
}
|
||||||
|
obj, err := GetCountyByGeocode(key, principal)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return obj.Name
|
||||||
|
}
|
||||||
|
obj, ok := placeCache.get(key)
|
||||||
|
if ok {
|
||||||
|
return obj.SalesTaxRate.County
|
||||||
|
}
|
||||||
|
place, err := getPlaceByGeocode(key, principal)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return place.SalesTaxRate.County
|
||||||
|
}
|
||||||
|
func GetCountyByGeocode(recordID string, principal *User) (geo_models.County, error) {
|
||||||
|
sugar.Debug("plex.getCountyByGeocode: 📥")
|
||||||
|
if recordID == "" {
|
||||||
|
return geo_models.County{}, fmt.Errorf("plex.getCountyByID: 💣 ⛔ key is blank")
|
||||||
|
}
|
||||||
|
obj, ok := countyCache.get(recordID)
|
||||||
|
if ok {
|
||||||
|
sugar.Debug("plex.getCountyByGeocode: 👍 🎯 📤")
|
||||||
|
return *obj, nil
|
||||||
|
}
|
||||||
|
geoParams := county.NewGetCountiesParamsWithTimeout(getTimeout)
|
||||||
|
geoParams.Geocode = &recordID
|
||||||
|
response, err := geoClient.County.GetCounties(geoParams, principal.Auth)
|
||||||
|
if err != nil {
|
||||||
|
return geo_models.County{}, err
|
||||||
|
}
|
||||||
|
var newObj *geo_models.County
|
||||||
|
for _, itm := range response.Payload.Data { // single iteration execution
|
||||||
|
newObj = itm
|
||||||
|
}
|
||||||
|
countyCache.put(recordID, newObj)
|
||||||
|
sugar.Debug("plex.getCountyByGeocode: 👍 🆕 📤")
|
||||||
|
return *newObj, nil
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"code.tnxs.net/taxnexus/lib/api/geo/geo_models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var placeCache = placeCacheType{
|
||||||
|
obj: map[string]*geo_models.Place{},
|
||||||
|
}
|
||||||
|
|
||||||
|
type placeCacheType struct {
|
||||||
|
sync.RWMutex
|
||||||
|
obj map[string]*geo_models.Place
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *placeCacheType) get(key string) (*geo_models.Place, bool) {
|
||||||
|
m.RLock()
|
||||||
|
defer m.RUnlock()
|
||||||
|
r, ok := m.obj[key]
|
||||||
|
return r, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *placeCacheType) put(key string, obj *geo_models.Place) {
|
||||||
|
m.Lock()
|
||||||
|
defer m.Unlock()
|
||||||
|
m.obj[key] = obj
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"code.tnxs.net/taxnexus/lib/api/geo/geo_client/place"
|
||||||
|
"code.tnxs.net/taxnexus/lib/api/geo/geo_models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getPlaceNameByGeocode(key string, principal *User) string {
|
||||||
|
if key == "" || len(key) == 7 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
p, ok := placeCache.get(key)
|
||||||
|
if ok {
|
||||||
|
return p.Name
|
||||||
|
}
|
||||||
|
thePlace, err := getPlaceByGeocode(key, principal)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return thePlace.Name
|
||||||
|
}
|
||||||
|
func getPlaceHasDistrictTaxesByGeocode(key string, principal *User) bool {
|
||||||
|
if key == "" || len(key) == 7 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p, ok := placeCache.get(key)
|
||||||
|
if ok {
|
||||||
|
return p.HasDistrictTaxes
|
||||||
|
}
|
||||||
|
thePlace, err := getPlaceByGeocode(key, principal)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return thePlace.HasDistrictTaxes
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPlaceByGeocode(recordID string, principal *User) (geo_models.Place, error) {
|
||||||
|
sugar.Debug("plex.getPlaceByGeocode: 📥")
|
||||||
|
if recordID == "" {
|
||||||
|
return geo_models.Place{}, fmt.Errorf("plex.getPlaceByID: 💣 ⛔ key is blank")
|
||||||
|
}
|
||||||
|
obj, ok := placeCache.get(recordID)
|
||||||
|
if ok {
|
||||||
|
sugar.Debug("plex.getPlaceByGeocode: 👍 🎯 📤")
|
||||||
|
return *obj, nil
|
||||||
|
}
|
||||||
|
geoParams := place.NewGetPlacesParamsWithTimeout(getTimeout)
|
||||||
|
geoParams.Geocode = &recordID
|
||||||
|
response, err := geoClient.Place.GetPlaces(geoParams, principal.Auth)
|
||||||
|
if err != nil {
|
||||||
|
return geo_models.Place{}, err
|
||||||
|
}
|
||||||
|
var newObj *geo_models.Place
|
||||||
|
for _, itm := range response.Payload.Data { // single iteration execution
|
||||||
|
newObj = itm
|
||||||
|
}
|
||||||
|
placeCache.put(recordID, newObj)
|
||||||
|
sugar.Debug("plex.getPlaceByGeocode: 👍 🆕 📤")
|
||||||
|
return *newObj, nil
|
||||||
|
}
|
|
@ -6,6 +6,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.tnxs.net/taxnexus/lib/api/auth0/auth0_client"
|
"code.tnxs.net/taxnexus/lib/api/auth0/auth0_client"
|
||||||
|
"code.tnxs.net/taxnexus/lib/api/crm/crm_client"
|
||||||
|
"code.tnxs.net/taxnexus/lib/api/geo/geo_client"
|
||||||
"code.tnxs.net/taxnexus/lib/app/logger"
|
"code.tnxs.net/taxnexus/lib/app/logger"
|
||||||
httptransport "github.com/go-openapi/runtime/client"
|
httptransport "github.com/go-openapi/runtime/client"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
@ -17,6 +19,8 @@ var sugar *zap.SugaredLogger
|
||||||
var config = Configuration{}
|
var config = Configuration{}
|
||||||
var appViper = viper.New()
|
var appViper = viper.New()
|
||||||
var auth0Client = auth0_client.Default
|
var auth0Client = auth0_client.Default
|
||||||
|
var geoClient = geo_client.Default
|
||||||
|
var crmClient = crm_client.Default
|
||||||
var configured = false
|
var configured = false
|
||||||
var apiUsers map[string]User
|
var apiUsers map[string]User
|
||||||
|
|
||||||
|
@ -27,6 +31,8 @@ const dateFormat = "2006-01-02"
|
||||||
const dateTimeFormat = "2006-01-02T15:04:05-0800"
|
const dateTimeFormat = "2006-01-02T15:04:05-0800"
|
||||||
const dateTimeFormatAlt = "2006-01-02 15:04:05"
|
const dateTimeFormatAlt = "2006-01-02 15:04:05"
|
||||||
const dateFormatAlt = "1/2/2006"
|
const dateFormatAlt = "1/2/2006"
|
||||||
|
const countyGeocodeLength = 7
|
||||||
|
const cityGeocodeLength = 12
|
||||||
|
|
||||||
// InitConfig exports the config initialization func
|
// InitConfig exports the config initialization func
|
||||||
func InitConfig(systemName string, initalLogLevel zapcore.Level) {
|
func InitConfig(systemName string, initalLogLevel zapcore.Level) {
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
var taxTypeCache = taxTypeCacheType{
|
||||||
|
obj: map[string]*TaxType{},
|
||||||
|
}
|
||||||
|
|
||||||
|
type taxTypeCacheType struct {
|
||||||
|
sync.RWMutex
|
||||||
|
obj map[string]*TaxType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *taxTypeCacheType) get(recordID string) (*TaxType, bool) {
|
||||||
|
m.RLock()
|
||||||
|
defer m.RUnlock()
|
||||||
|
r, ok := m.obj[recordID]
|
||||||
|
return r, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *taxTypeCacheType) put(recordID string, itm *TaxType) {
|
||||||
|
m.Lock()
|
||||||
|
defer m.Unlock()
|
||||||
|
m.obj[recordID] = itm
|
||||||
|
}
|
|
@ -0,0 +1,162 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.tnxs.net/taxnexus/lib/api/geo/geo_client/tax_type"
|
||||||
|
)
|
||||||
|
|
||||||
|
var exciseCategories = map[string]string{
|
||||||
|
"cannabis-excise": "cannabis-excise",
|
||||||
|
"telecom-excise": "telecom-excise",
|
||||||
|
}
|
||||||
|
|
||||||
|
var cannabisCategories = map[string]string{
|
||||||
|
"cannabis-ag": "cannabis-ag",
|
||||||
|
"cannabis-trade": "cannabis-trade",
|
||||||
|
}
|
||||||
|
|
||||||
|
var merchCategories = map[string]string{
|
||||||
|
"cannabis-retail": "cannabis-retail",
|
||||||
|
"sales-district": "sales-district",
|
||||||
|
"sales-state": "sales-state",
|
||||||
|
}
|
||||||
|
|
||||||
|
var telecomCategories = map[string]string{
|
||||||
|
"broadband": "broadband",
|
||||||
|
"voip-interstate": "voip-interstate",
|
||||||
|
"voip-international": "voip-international",
|
||||||
|
"voip-intrastate": "voip-intrastate",
|
||||||
|
"voip": "voip",
|
||||||
|
"wireless": "wireless",
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTaxType(recordID string, principal *User) *TaxType {
|
||||||
|
obj, ok := taxTypeCache.get(recordID)
|
||||||
|
if ok {
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
obj, err := GetTaxTypeByID(recordID, principal)
|
||||||
|
if err != nil {
|
||||||
|
return &TaxType{}
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
func GetTaxTypeByID(recordID string, principal *User) (*TaxType, error) {
|
||||||
|
sugar.Debugf("render.GetTaxTypesByID: 📥")
|
||||||
|
if recordID == "" {
|
||||||
|
return nil, fmt.Errorf("render.getTaxTypeByID: 💣 ⛔ key is blank")
|
||||||
|
}
|
||||||
|
obj, ok := taxTypeCache.get(recordID)
|
||||||
|
if ok {
|
||||||
|
sugar.Debugf("render.getTaxTypeByID: 👍 🎯 📤")
|
||||||
|
return obj, nil
|
||||||
|
}
|
||||||
|
geoParams := tax_type.NewGetTaxTypesParamsWithTimeout(getTimeout)
|
||||||
|
geoParams.TaxTypeID = &recordID
|
||||||
|
response, err := geoClient.TaxType.GetTaxTypes(geoParams, principal.Auth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var newObj *TaxType
|
||||||
|
for _, itm := range response.Payload.Data { // single iteration execution
|
||||||
|
newObj = UnMarshalTaxType(itm)
|
||||||
|
}
|
||||||
|
taxTypeCache.put(recordID, newObj)
|
||||||
|
sugar.Debugf("render.getTaxTypeByID: 👍 🆕 📤")
|
||||||
|
return newObj, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCannabisTaxTypes(taxTypes []*TaxType) []*TaxType {
|
||||||
|
objList := []*TaxType{}
|
||||||
|
for _, tt := range taxTypes {
|
||||||
|
if tt != nil {
|
||||||
|
if inMap(cannabisCategories, tt.Category) && isActive(tt) {
|
||||||
|
sugar.Debugf("render.getCannabisTaxTypes: ➡ ✅ cannabis %s", tt.Name)
|
||||||
|
objList = append(objList, tt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(objList) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return objList
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetExciseTaxTypes(taxTypes []*TaxType) []*TaxType {
|
||||||
|
objList := []*TaxType{}
|
||||||
|
for _, tt := range taxTypes {
|
||||||
|
if tt != nil {
|
||||||
|
if inMap(exciseCategories, tt.Category) && isActive(tt) {
|
||||||
|
sugar.Debugf("render.getExciseTaxTypes: ➡ ✅ excise %s", tt.Name)
|
||||||
|
objList = append(objList, tt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(objList) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return objList
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBusinessTaxTypes(taxTypes []*TaxType) []*TaxType {
|
||||||
|
objList := []*TaxType{}
|
||||||
|
for _, tt := range taxTypes {
|
||||||
|
if tt != nil {
|
||||||
|
if tt.Category == "business" && isActive(tt) {
|
||||||
|
sugar.Debugf("render.getBusinessTaxTypes: ➡ ✅ business %s", tt.Name)
|
||||||
|
objList = append(objList, tt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(objList) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return objList
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMerchTaxTypes(taxTypes []*TaxType) []*TaxType {
|
||||||
|
objList := []*TaxType{}
|
||||||
|
for _, tt := range taxTypes {
|
||||||
|
if tt != nil {
|
||||||
|
if inMap(merchCategories, tt.Category) && isActive(tt) {
|
||||||
|
sugar.Debugf("render.getMerchTaxTypes: ➡ ✅ merch %s", tt.Name)
|
||||||
|
objList = append(objList, tt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(objList) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return objList
|
||||||
|
}
|
||||||
|
func GetTelecomTaxTypes(taxTypes []*TaxType) []*TaxType {
|
||||||
|
objList := []*TaxType{}
|
||||||
|
for _, tt := range taxTypes {
|
||||||
|
if tt != nil {
|
||||||
|
if inMap(telecomCategories, tt.Category) && isActive(tt) {
|
||||||
|
sugar.Debugf("render.getTelecomTaxTypes: ➡ ✅ telecom %s", tt.Name)
|
||||||
|
objList = append(objList, tt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(objList) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return objList
|
||||||
|
}
|
||||||
|
|
||||||
|
func inMap(values map[string]string, str string) bool {
|
||||||
|
_, ok := values[str]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
func isActive(tt *TaxType) bool {
|
||||||
|
if tt.EndDate.Time.IsZero() && tt.EffectiveDate.Time.Before(time.Now()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if tt.EndDate.Time.After(time.Now()) && tt.EffectiveDate.Time.Before(time.Now()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
|
@ -17,6 +17,7 @@ type TaxTypeActivityWrapper struct {
|
||||||
// TaxType is a first class object type
|
// TaxType is a first class object type
|
||||||
type TaxType struct {
|
type TaxType struct {
|
||||||
ID string
|
ID string
|
||||||
|
Account Account
|
||||||
AccountID string
|
AccountID string
|
||||||
AccountingRuleCode string
|
AccountingRuleCode string
|
||||||
Active bool
|
Active bool
|
||||||
|
@ -26,6 +27,7 @@ type TaxType struct {
|
||||||
Category string
|
Category string
|
||||||
CollectorDomainID string
|
CollectorDomainID string
|
||||||
CompanyID string
|
CompanyID string
|
||||||
|
Contact Contact
|
||||||
ContactID string
|
ContactID string
|
||||||
CreatedByID string
|
CreatedByID string
|
||||||
CreatedDate sql.NullTime
|
CreatedDate sql.NullTime
|
||||||
|
@ -40,6 +42,7 @@ type TaxType struct {
|
||||||
FilingPostalcode string
|
FilingPostalcode string
|
||||||
FilingState string
|
FilingState string
|
||||||
FilingStreet string
|
FilingStreet string
|
||||||
|
Formatted taxTypeFormatted
|
||||||
Fractional bool
|
Fractional bool
|
||||||
Frequency string
|
Frequency string
|
||||||
GeocodeString string
|
GeocodeString string
|
||||||
|
@ -67,3 +70,18 @@ type TaxType struct {
|
||||||
UnitBase float64
|
UnitBase float64
|
||||||
Units string
|
Units string
|
||||||
}
|
}
|
||||||
|
type taxTypeFormatted struct {
|
||||||
|
CreatedDate string
|
||||||
|
EffectiveDate string
|
||||||
|
EndDate string
|
||||||
|
Fractional string
|
||||||
|
InterestRate string
|
||||||
|
IsMedicinal string
|
||||||
|
IsRecreational string
|
||||||
|
LastModifiedDate string
|
||||||
|
MarkupRate string
|
||||||
|
PassThrough string
|
||||||
|
PenaltyDays string
|
||||||
|
PenaltyRate string
|
||||||
|
Rate string
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue