Created
July 11, 2016 08:54
-
-
Save abelevtsov/26adc740a0018faa587a333ed90868e2 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
using System.ServiceModel; | |
using Microsoft.Crm.Sdk.Messages; | |
using Microsoft.Xrm.Sdk; | |
using Microsoft.Xrm.Sdk.Messages; | |
using Microsoft.Xrm.Sdk.Metadata; | |
using Microsoft.Xrm.Sdk.Query; | |
using MoreLinq; | |
using NLog; | |
using Rosagroleasing.Crm.Core.Interfaces; | |
using Rosagroleasing.Crm.Core.Interfaces.Entities; | |
namespace Rosagroleasing.Crm.Core | |
{ | |
public class CrmProvider | |
{ | |
protected static readonly Logger Logger = LogManager.GetLogger("CRM"); | |
public Guid UserId { get; protected set; } | |
public Guid OrganizationId { get; protected set; } | |
public IOrganizationService SystemService { get; set; } | |
public IOrganizationService UserService { get; set; } | |
/// <summary> | |
/// Gets id of current user | |
/// </summary> | |
public virtual Guid GetUserId() | |
{ | |
var me = (WhoAmIResponse)SystemService.Execute(new WhoAmIRequest()); | |
return me.UserId; | |
} | |
/// <summary> | |
/// Get settings per user | |
/// </summary> | |
public Entity GetUserSettings() | |
{ | |
var userId = GetUserId(); | |
return userId != Guid.Empty ? GetUserSettings(userId) : null; | |
} | |
/// <summary> | |
/// Get setting for specified user | |
/// </summary> | |
/// <param name="userId">User Id</param> | |
public Entity GetUserSettings(Guid userId) | |
{ | |
if (userId != Guid.Empty) | |
{ | |
var request = | |
new RetrieveUserSettingsSystemUserRequest | |
{ | |
EntityId = userId, | |
ColumnSet = new ColumnSet(true) | |
}; | |
try | |
{ | |
var userSettings = SystemService.Execute(request) as RetrieveUserSettingsSystemUserResponse; | |
return userSettings == null || userSettings.Entity == null ? null : userSettings.Entity; | |
} | |
catch (FaultException<OrganizationServiceFault>) | |
{ | |
return null; | |
} | |
} | |
return null; | |
} | |
/// <summary> | |
/// Get teams of user | |
/// </summary> | |
/// <param name="userId">User Id, current user Id by default</param> | |
public IEnumerable<Entity> GetUserTeams(Guid? userId = null) | |
{ | |
var currentUserId = userId.HasValue && userId.Value != Guid.Empty ? userId.Value : GetUserId(); | |
var query = new QueryExpression("team") | |
{ | |
ColumnSet = new ColumnSet(true) | |
}; | |
query.LinkEntities.Add(new LinkEntity | |
{ | |
LinkFromEntityName = "team", | |
LinkToEntityName = "teammembership", | |
LinkFromAttributeName = "teamid", | |
LinkToAttributeName = "teamid", | |
LinkCriteria = new FilterExpression | |
{ | |
Conditions = | |
{ | |
new ConditionExpression("systemuserid", ConditionOperator.Equal, currentUserId) | |
} | |
} | |
}); | |
return SystemService.RetrieveMultiple(query).Entities; | |
} | |
/// <summary> | |
/// Gets id of current organization | |
/// </summary> | |
public virtual Guid GetOrganizationId() | |
{ | |
var me = (WhoAmIResponse)SystemService.Execute(new WhoAmIRequest()); | |
return me.OrganizationId; | |
} | |
/// <summary> | |
/// Gets name of current organization | |
/// </summary> | |
public virtual string GetOrganizationName() | |
{ | |
var orgId = GetOrganizationId(); | |
var org = SystemService.Retrieve("organization", orgId, new ColumnSet("name")); | |
return org["name"] as string; | |
} | |
/// <summary> | |
/// Gets current language id | |
/// </summary> | |
public string GetCurrentLcid() | |
{ | |
var userSettings = GetUserSettings(); | |
if (userSettings == null) | |
{ | |
return GetOrganizationBaseLanguageLcid().ToString(); | |
} | |
int currentLcid; | |
int.TryParse(userSettings.Attributes["uilanguageid"].ToString(), out currentLcid); | |
return (currentLcid != 0 ? currentLcid : GetOrganizationBaseLanguageLcid()).ToString(); | |
} | |
/// <summary> | |
/// Gets base currency id. | |
/// </summary> | |
/// <returns>Currency id of the organization.</returns> | |
public Guid GetBaseCurrencyId() | |
{ | |
var orgId = GetOrganizationId(); | |
var org = SystemService.Retrieve("organization", orgId, new ColumnSet("basecurrencyid")); | |
var baseCurrencyId = (EntityReference)org["basecurrencyid"]; | |
return baseCurrencyId != null ? baseCurrencyId.Id : Guid.Empty; | |
} | |
/// <summary> | |
/// Gets base language code. | |
/// </summary> | |
/// <returns>Base language code of the organization.</returns> | |
public int? GetOrganizationBaseLanguageLcid() | |
{ | |
var org = SystemService.Retrieve("organization", GetOrganizationId(), new ColumnSet("languagecode")); | |
var languageCode = (int?)org["languagecode"]; | |
return languageCode ?? 1033; | |
} | |
/// <summary> | |
/// Creates link between two entity instances in a many-to-many relationship | |
/// </summary> | |
public void CreateManyToManyRelationship(string firstEntityName, Guid firstEntityId, string secondEntityName, Guid secondEntityId, string roleName, bool userOwned = false) | |
{ | |
var request = | |
new AssociateEntitiesRequest | |
{ | |
Moniker1 = new EntityReference(firstEntityName, firstEntityId), | |
Moniker2 = new EntityReference(secondEntityName, secondEntityId), | |
RelationshipName = roleName | |
}; | |
var orgService = GetOrgService(userOwned); | |
orgService.Execute(request); | |
} | |
/// <summary> | |
/// Creates link between two entity instances in a many-to-many relationship | |
/// </summary> | |
public void CreateManyToManyRelationship(EntityReference firstEntity, EntityReference secondEntity, string roleName, bool userOwned = false) | |
{ | |
var request = | |
new AssociateEntitiesRequest | |
{ | |
Moniker1 = firstEntity, | |
Moniker2 = secondEntity, | |
RelationshipName = roleName | |
}; | |
var orgService = GetOrgService(userOwned); | |
orgService.Execute(request); | |
} | |
/// <summary> | |
/// Initializes one entity based on another. Uses Crm Mapping to fill fields based on parent entity fields | |
/// </summary> | |
/// <returns>initialized entity</returns> | |
public Entity InitializeEntityFromAnother(string targetEntityName, string sourceEntityName, Guid sourceId, bool userOwned = false) | |
{ | |
var request = | |
new InitializeFromRequest | |
{ | |
EntityMoniker = new EntityReference(sourceEntityName, sourceId), | |
TargetEntityName = targetEntityName, | |
TargetFieldType = TargetFieldType.All | |
}; | |
var orgService = GetOrgService(userOwned); | |
var response = (InitializeFromResponse)orgService.Execute(request); | |
return response.Entity; | |
} | |
/// <summary> | |
/// Change state of an entity | |
/// </summary> | |
public void SetState(string entityName, Guid entityId, int statecode, int statuscode, bool userOwned = false) | |
{ | |
var request = | |
new SetStateRequest | |
{ | |
EntityMoniker = new EntityReference(entityName, entityId), | |
State = new OptionSetValue(statecode), | |
Status = new OptionSetValue(statuscode) | |
}; | |
var orgService = GetOrgService(userOwned); | |
orgService.Execute(request); | |
} | |
/// <summary> | |
/// Get current ObjectTypeCode of entity | |
/// </summary> | |
/// <param name="entityLogicalName">Entity logical name</param> | |
/// <returns>ObjectTypeCode value if found, otherwise int.MinValue</returns> | |
public int? GetObjectTypeCode(string entityLogicalName) | |
{ | |
var request = new RetrieveEntityRequest | |
{ | |
LogicalName = entityLogicalName | |
}; | |
var response = (RetrieveEntityResponse)SystemService.Execute(request); | |
return response.EntityMetadata.ObjectTypeCode; | |
} | |
/// <summary> | |
/// Add annotation to entity | |
/// </summary> | |
/// <param name="entity">Entity to add annotation</param> | |
/// <param name="description">Annotation description</param> | |
/// <param name="fileName">Attachment file name</param> | |
/// <param name="fileBytes">File bytes</param> | |
/// <returns>Id of added annotation</returns> | |
public Guid AddAnnotation(EntityReference entity, string description, string fileName, byte[] fileBytes) | |
{ | |
var annotation = new Entity("annotation"); | |
if (!string.IsNullOrWhiteSpace(description)) | |
{ | |
annotation["subject"] = description; | |
} | |
if (!string.IsNullOrWhiteSpace(fileName) && fileBytes != null && fileBytes.Length > 0) | |
{ | |
annotation["filename"] = fileName; | |
annotation["documentbody"] = Convert.ToBase64String(fileBytes); | |
} | |
if (annotation.Attributes.Count == 0) | |
{ | |
return Guid.Empty; | |
} | |
annotation["objecttypecode"] = entity.LogicalName; | |
annotation["objectid"] = entity; | |
return SystemService.Create(annotation); | |
} | |
/// <summary> | |
/// Retrieve all entities from CRM (instead first 5000 by default) | |
/// </summary> | |
public IEnumerable<T> RetrieveMultipleAllPages<T>(QueryExpression query, int batchSize = 5000, bool userOwned = false) where T : Entity | |
{ | |
if (query == null) | |
{ | |
throw new ArgumentNullException("query"); | |
} | |
query.PageInfo = | |
new PagingInfo | |
{ | |
PageNumber = 1, | |
Count = batchSize, | |
ReturnTotalRecordCount = false | |
}; | |
var orgService = GetOrgService(userOwned); | |
var entitiesCollection = orgService.RetrieveMultiple(query); | |
var entities = entitiesCollection.Entities; | |
while (entitiesCollection.MoreRecords) | |
{ | |
query.PageInfo.PageNumber++; | |
query.PageInfo.PagingCookie = entitiesCollection.PagingCookie; | |
entitiesCollection = orgService.RetrieveMultiple(query); | |
entities.AddRange(entitiesCollection.Entities); | |
} | |
return entities.Cast<T>(); | |
} | |
public IEnumerable<T> RetrieveMultipleAllPages<T>(QueryByAttribute query, int batchSize = 5000, bool userOwned = false) where T : Entity | |
{ | |
if (query == null) | |
{ | |
throw new ArgumentNullException("query"); | |
} | |
if (query.PageInfo == null) | |
{ | |
query.PageInfo = | |
new PagingInfo | |
{ | |
PageNumber = 1, | |
Count = batchSize, | |
ReturnTotalRecordCount = false | |
}; | |
} | |
var orgService = GetOrgService(userOwned); | |
var entitiesCollection = orgService.RetrieveMultiple(query); | |
var entities = entitiesCollection.Entities; | |
while (entitiesCollection.MoreRecords) | |
{ | |
query.PageInfo.PageNumber++; | |
query.PageInfo.PagingCookie = entitiesCollection.PagingCookie; | |
entitiesCollection = orgService.RetrieveMultiple(query); | |
entities.AddRange(entitiesCollection.Entities); | |
} | |
return entities.Cast<T>(); | |
} | |
public IEnumerable<T> ExecuteMultiple<T>(IEnumerable<T> entities, Func<T, OrganizationRequest> requestBuilder, int batchSize = 1000, bool userOwned = false) where T : Entity | |
{ | |
var records = entities as IList<T> ?? entities.ToList(); | |
var faultedRequests = new List<OrganizationRequest>(); | |
var orgService = GetOrgService(userOwned); | |
foreach (var batch in records.Batch(batchSize)) | |
{ | |
var request = | |
new ExecuteMultipleRequest | |
{ | |
Settings = | |
new ExecuteMultipleSettings | |
{ | |
ContinueOnError = true, | |
ReturnResponses = true | |
}, | |
Requests = new OrganizationRequestCollection() | |
}; | |
foreach (var entity in batch) | |
{ | |
request.Requests.Add(requestBuilder(entity)); | |
} | |
var response = (ExecuteMultipleResponse)orgService.Execute(request); | |
var faultedResponses = response.Responses.Where(responseItem => responseItem.Fault != null).ToList(); | |
foreach (var faultedResponse in faultedResponses) | |
{ | |
LogExecuteMultipleFault(faultedResponse.Fault); | |
} | |
faultedRequests.AddRange(faultedResponses.Select(responseItem => request.Requests[responseItem.RequestIndex]).ToList()); | |
} | |
var unprocessed = faultedRequests.Select(GetRequestTarget<T>); | |
return records.Except(unprocessed); | |
} | |
public void Associate(Entity current, IEnumerable<Entity> entitiesToAssociate, string relationshipName, bool userOwned = false) | |
{ | |
var relatedEntities = new EntityReferenceCollection(); | |
relatedEntities.AddRange(entitiesToAssociate.Where(e => e != null).Select(e => e.ToEntityReference())); | |
var relationship = new Relationship(relationshipName); | |
var orgService = GetOrgService(userOwned); | |
orgService.Associate(current.LogicalName, current.Id, relationship, relatedEntities); | |
} | |
public void Disassociate(Entity current, IEnumerable<Entity> entitiesToDisassociate, string relationshipName, bool deleteEntities, bool userOwned = false) | |
{ | |
var entities = new EntityReferenceCollection(); | |
var enumerable = entitiesToDisassociate as IList<Entity> ?? entitiesToDisassociate.ToList(); | |
entities.AddRange(enumerable.Where(e => e != null).Select(e => e.ToEntityReference())); | |
var relationship = new Relationship(relationshipName); | |
var orgService = GetOrgService(userOwned); | |
orgService.Disassociate(current.LogicalName, current.Id, relationship, entities); | |
if (deleteEntities) | |
{ | |
foreach (var entityToDelete in enumerable) | |
{ | |
orgService.Delete(entityToDelete.LogicalName, entityToDelete.Id); | |
} | |
} | |
} | |
public void RestoreRecordFromAudit(string entityLogicalName, Guid entityId) | |
{ | |
var changeRequest = new RetrieveRecordChangeHistoryRequest | |
{ | |
Target = new EntityReference(entityLogicalName, entityId) | |
}; | |
var changeResponse = (RetrieveRecordChangeHistoryResponse)SystemService.Execute(changeRequest); | |
var details = changeResponse.AuditDetailCollection; | |
for (var count = 0; count < details.Count; count++) | |
{ | |
var detail = details[count] as AttributeAuditDetail; | |
if (detail == null) | |
{ | |
continue; | |
} | |
if (detail.NewValue != null || detail.OldValue == null) | |
{ | |
continue; | |
} | |
// Восстанавливаем запись | |
var entity = detail.OldValue; | |
SystemService.Create(entity); | |
break; | |
} | |
} | |
/// <summary> | |
/// Get timezone of current user | |
/// </summary> | |
public int GetCurrentUserTimeZone() | |
{ | |
var userSettings = GetUserSettings(); | |
return userSettings == null ? 0 : userSettings.GetAttributeValue<int>("timezonecode"); | |
} | |
/// <summary> | |
/// Get local time | |
/// </summary> | |
/// <param name="utcTime">Current UTC time</param> | |
/// <param name="timeZoneCode">Code of time zone</param> | |
/// <returns>Local time</returns> | |
public DateTime? RetrieveLocalTimeFromUTCTime(DateTime? utcTime, int? timeZoneCode) | |
{ | |
if (!utcTime.HasValue) | |
{ | |
return null; | |
} | |
if (!timeZoneCode.HasValue) | |
{ | |
return DateTime.UtcNow; | |
} | |
return RetrieveLocalTimeFromUTCTime(utcTime.Value, timeZoneCode.Value); | |
} | |
/// <summary> | |
/// Returns user local datetime | |
/// </summary> | |
/// <param name="date">Date probably UTC</param> | |
/// <param name="userId">User identifier</param> | |
/// <param name="userdate">Conversion result</param> | |
public bool TryGetUserDateTime(DateTime? date, Guid userId, out DateTime? userdate) | |
{ | |
try | |
{ | |
var timezonecode = (int?)GetUserSettings(userId)["timezonecode"]; | |
userdate = RetrieveLocalTimeFromUTCTime(date, timezonecode); | |
return true; | |
} | |
catch | |
{ | |
Logger.Warn("Systemuser (Id = '{0}') doesn't have timezone settings", userId); | |
userdate = null; | |
return false; | |
} | |
} | |
/// <summary> | |
/// Get UTC time | |
/// </summary> | |
/// <param name="localTime">Current local time</param> | |
/// <param name="timeZoneCode">Code of time zone</param> | |
/// <returns>UTC time</returns> | |
public DateTime RetrieveUTCTimeFromLocalTime(DateTime localTime, int timeZoneCode) | |
{ | |
var request = new UtcTimeFromLocalTimeRequest | |
{ | |
TimeZoneCode = timeZoneCode, | |
LocalTime = localTime | |
}; | |
var response = (UtcTimeFromLocalTimeResponse)SystemService.Execute(request); | |
return response.UtcTime; | |
} | |
/// <summary> | |
/// Назначить ответственного | |
/// </summary> | |
public void AssignOwner(EntityReference target, EntityReference owner, bool userOwned = false) | |
{ | |
if (owner == null) | |
{ | |
return; | |
} | |
var orgService = GetOrgService(userOwned); | |
try | |
{ | |
var request = | |
new AssignRequest | |
{ | |
Assignee = owner, | |
Target = target | |
}; | |
orgService.Execute(request); | |
} | |
catch (Exception ex) | |
{ | |
throw new InvalidPluginExecutionException(string.Format("Не удалось назначить на сущности {0} ответственного. Ошибка: {1}", target.LogicalName, ex.Message)); | |
} | |
} | |
/// <summary> | |
/// Grant shared access on target entity with principal | |
/// </summary> | |
public void GrantAccess(EntityReference target, EntityReference principal, AccessRights accessRights) | |
{ | |
var grantAccessRequest = new GrantAccessRequest | |
{ | |
PrincipalAccess = new PrincipalAccess | |
{ | |
AccessMask = accessRights, | |
Principal = principal | |
}, | |
Target = target | |
}; | |
SystemService.Execute(grantAccessRequest); | |
} | |
/// <summary> | |
/// Modify shared access on target entity with principal | |
/// </summary> | |
public void ModifyAccess(EntityReference target, EntityReference principal, AccessRights accessRights) | |
{ | |
var modifyAccessRequest = new ModifyAccessRequest | |
{ | |
PrincipalAccess = new PrincipalAccess | |
{ | |
AccessMask = accessRights, | |
Principal = principal | |
}, | |
Target = target | |
}; | |
SystemService.Execute(modifyAccessRequest); | |
} | |
/// <summary> | |
/// Revoke shared access on target entity with revokee | |
/// </summary> | |
public void RevokeAccess(EntityReference target, EntityReference revokee) | |
{ | |
var revokeAccessRequest = new RevokeAccessRequest | |
{ | |
Revokee = revokee, | |
Target = target | |
}; | |
SystemService.Execute(revokeAccessRequest); | |
} | |
/// <summary> | |
/// Get attachments maximum file size | |
/// </summary> | |
public int GetMaxUploadFileSize() | |
{ | |
var query = new QueryExpression("organization") | |
{ | |
ColumnSet = new ColumnSet("maxuploadfilesize") | |
}; | |
query.Criteria.AddCondition("organizationid", ConditionOperator.Equal, GetOrganizationId()); | |
var org = SystemService.RetrieveMultiple(query).Entities.FirstOrDefault(); | |
if (org != null) | |
{ | |
return (int)org["maxuploadfilesize"]; | |
} | |
return 0; | |
} | |
/// <summary> | |
/// Execute workflow on entity | |
/// </summary> | |
/// <returns>AsyncOperation Id</returns> | |
public Guid ExecuteWorkflow(Guid workflowId, Guid entityId, bool userOwned = false) | |
{ | |
if (workflowId == Guid.Empty || entityId == Guid.Empty) | |
{ | |
return Guid.Empty; | |
} | |
var request = | |
new ExecuteWorkflowRequest | |
{ | |
WorkflowId = workflowId, | |
EntityId = entityId | |
}; | |
var orgService = GetOrgService(userOwned); | |
var response = (ExecuteWorkflowResponse)orgService.Execute(request); | |
return response.Id; | |
} | |
public string GetOptionSetLabel(string optionSetName, OptionSetValue selectedOptionSetValue) | |
{ | |
var request = new RetrieveOptionSetRequest | |
{ | |
Name = optionSetName | |
}; | |
var baseLanguage = GetOrganizationBaseLanguageLcid(); | |
return (from option in ((OptionSetMetadata)((RetrieveOptionSetResponse)SystemService.Execute(request)).OptionSetMetadata).Options | |
where (option.Value != selectedOptionSetValue.Value ? 0 : option.Value) != 0 | |
let localizedLabel = | |
(from ll in option.Label.LocalizedLabels | |
where ll.LanguageCode == baseLanguage | |
select ll).FirstOrDefault() | |
select localizedLabel == null | |
? option.Label.UserLocalizedLabel.Label | |
: localizedLabel.Label).FirstOrDefault(); | |
} | |
private static T GetRequestTarget<T>(OrganizationRequest request) where T : Entity | |
{ | |
var createRequest = request as CreateRequest; | |
if (createRequest != null) | |
{ | |
return (T)createRequest.Target; | |
} | |
var updateRequest = request as UpdateRequest; | |
if (updateRequest != null) | |
{ | |
return (T)updateRequest.Target; | |
} | |
var setstateRequest = request as SetStateRequest; | |
if (setstateRequest != null) | |
{ | |
return new Entity(setstateRequest.EntityMoniker.LogicalName) | |
{ | |
Id = setstateRequest.EntityMoniker.Id | |
}.ToEntity<T>(); | |
} | |
return default(T); | |
} | |
private DateTime RetrieveLocalTimeFromUTCTime(DateTime utcTime, int timeZoneCode) | |
{ | |
var request = new LocalTimeFromUtcTimeRequest | |
{ | |
TimeZoneCode = timeZoneCode, | |
UtcTime = utcTime.ToUniversalTime() | |
}; | |
var response = (LocalTimeFromUtcTimeResponse)SystemService.Execute(request); | |
return response.LocalTime; | |
} | |
private IEnumerable<T> GetEntities<T>( | |
string entityName, | |
IEnumerable<string> attributesNames = null, | |
FilterExpression criteria = null, | |
IEnumerable<LinkEntity> linkedEntities = null, | |
IEnumerable<OrderExpression> orders = null, | |
bool noLock = false, | |
bool distinct = false, | |
PagingInfo pagingInfo = null) where T : Entity | |
{ | |
var query = new QueryExpression(entityName) | |
{ | |
ColumnSet = TranslateColumnSet(attributesNames) | |
}; | |
if (criteria != null) | |
{ | |
query.Criteria = criteria; | |
} | |
if (linkedEntities != null) | |
{ | |
query.LinkEntities.AddRange(linkedEntities); | |
} | |
query.Distinct = distinct; | |
if (orders != null) | |
{ | |
query.Orders.AddRange(orders); | |
} | |
if (pagingInfo != null) | |
{ | |
query.PageInfo = pagingInfo; | |
} | |
query.NoLock = noLock; | |
return SystemService.RetrieveMultiple(query).Entities.Cast<T>(); | |
} | |
private ColumnSet TranslateColumnSet(IEnumerable<string> columnSet) | |
{ | |
var result = new ColumnSet(); | |
if (columnSet != null) | |
{ | |
var enumerable = columnSet as IList<string> ?? columnSet.ToList(); | |
if (enumerable.Any()) | |
{ | |
result.AllColumns = false; | |
result.Columns.AddRange(enumerable); | |
return result; | |
} | |
} | |
result.AllColumns = true; | |
return result; | |
} | |
private void LogExecuteMultipleFault(OrganizationServiceFault fault) | |
{ | |
Logger.Error("Произошла ошибка выполнения запроса.\nErrorCode: {0},\nMessage: {1},\nErrorDetails: {2},\nTraceText: {3}", | |
fault.ErrorCode, | |
fault.Message, | |
fault.ErrorDetails, | |
fault.TraceText); | |
if (fault.InnerFault != null) | |
{ | |
LogExecuteMultipleFault(fault.InnerFault); | |
} | |
} | |
private IOrganizationService GetOrgService(bool userOwned) | |
{ | |
return userOwned ? UserService : SystemService; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment